index.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758
  1. import LanguageDetector from "i18next-browser-languagedetector";
  2. import { useCallback, useContext, useEffect, useRef, useState } from "react";
  3. import { trackEvent } from "../analytics";
  4. import { getDefaultAppState } from "../appState";
  5. import { ErrorDialog } from "../components/ErrorDialog";
  6. import { TopErrorBoundary } from "../components/TopErrorBoundary";
  7. import {
  8. APP_NAME,
  9. EVENT,
  10. TITLE_TIMEOUT,
  11. URL_HASH_KEYS,
  12. VERSION_TIMEOUT,
  13. } from "../constants";
  14. import { loadFromBlob } from "../data/blob";
  15. import { ImportedDataState } from "../data/types";
  16. import {
  17. ExcalidrawElement,
  18. FileId,
  19. NonDeletedExcalidrawElement,
  20. } from "../element/types";
  21. import { useCallbackRefState } from "../hooks/useCallbackRefState";
  22. import { Language, t } from "../i18n";
  23. import Excalidraw, {
  24. defaultLang,
  25. languages,
  26. } from "../packages/excalidraw/index";
  27. import {
  28. AppState,
  29. LibraryItems,
  30. ExcalidrawImperativeAPI,
  31. BinaryFileData,
  32. BinaryFiles,
  33. } from "../types";
  34. import {
  35. debounce,
  36. getVersion,
  37. isTestEnv,
  38. preventUnload,
  39. ResolvablePromise,
  40. resolvablePromise,
  41. } from "../utils";
  42. import {
  43. FIREBASE_STORAGE_PREFIXES,
  44. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  45. STORAGE_KEYS,
  46. SYNC_BROWSER_TABS_TIMEOUT,
  47. } from "./app_constants";
  48. import CollabWrapper, {
  49. CollabAPI,
  50. CollabContext,
  51. CollabContextConsumer,
  52. } from "./collab/CollabWrapper";
  53. import { LanguageList } from "./components/LanguageList";
  54. import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
  55. import {
  56. getLibraryItemsFromStorage,
  57. importFromLocalStorage,
  58. importUsernameFromLocalStorage,
  59. saveToLocalStorage,
  60. } from "./data/localStorage";
  61. import CustomStats from "./CustomStats";
  62. import { restoreAppState, RestoredDataState } from "../data/restore";
  63. import { Tooltip } from "../components/Tooltip";
  64. import { shield } from "../components/icons";
  65. import "./index.scss";
  66. import { ExportToExcalidrawPlus } from "./components/ExportToExcalidrawPlus";
  67. import { getMany, set, del, keys, createStore } from "idb-keyval";
  68. import { FileManager, updateStaleImageStatuses } from "./data/FileManager";
  69. import { newElementWith } from "../element/mutateElement";
  70. import { isInitializedImageElement } from "../element/typeChecks";
  71. import { loadFilesFromFirebase } from "./data/firebase";
  72. import {
  73. isBrowserStorageStateNewer,
  74. updateBrowserStateVersion,
  75. } from "./data/tabSync";
  76. const filesStore = createStore("files-db", "files-store");
  77. const clearObsoleteFilesFromIndexedDB = async (opts: {
  78. currentFileIds: FileId[];
  79. }) => {
  80. const allIds = await keys(filesStore);
  81. for (const id of allIds) {
  82. if (!opts.currentFileIds.includes(id as FileId)) {
  83. del(id, filesStore);
  84. }
  85. }
  86. };
  87. const localFileStorage = new FileManager({
  88. getFiles(ids) {
  89. return getMany(ids, filesStore).then(
  90. (filesData: (BinaryFileData | undefined)[]) => {
  91. const loadedFiles: BinaryFileData[] = [];
  92. const erroredFiles = new Map<FileId, true>();
  93. filesData.forEach((data, index) => {
  94. const id = ids[index];
  95. if (data) {
  96. loadedFiles.push(data);
  97. } else {
  98. erroredFiles.set(id, true);
  99. }
  100. });
  101. return { loadedFiles, erroredFiles };
  102. },
  103. );
  104. },
  105. async saveFiles({ addedFiles }) {
  106. const savedFiles = new Map<FileId, true>();
  107. const erroredFiles = new Map<FileId, true>();
  108. // before we use `storage` event synchronization, let's update the flag
  109. // optimistically. Hopefully nothing fails, and an IDB read executed
  110. // before an IDB write finishes will read the latest value.
  111. updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
  112. await Promise.all(
  113. [...addedFiles].map(async ([id, fileData]) => {
  114. try {
  115. await set(id, fileData, filesStore);
  116. savedFiles.set(id, true);
  117. } catch (error: any) {
  118. console.error(error);
  119. erroredFiles.set(id, true);
  120. }
  121. }),
  122. );
  123. return { savedFiles, erroredFiles };
  124. },
  125. });
  126. const languageDetector = new LanguageDetector();
  127. languageDetector.init({
  128. languageUtils: {
  129. formatLanguageCode: (langCode: Language["code"]) => langCode,
  130. isWhitelisted: () => true,
  131. },
  132. checkWhitelist: false,
  133. });
  134. const saveDebounced = debounce(
  135. async (
  136. elements: readonly ExcalidrawElement[],
  137. appState: AppState,
  138. files: BinaryFiles,
  139. onFilesSaved: () => void,
  140. ) => {
  141. saveToLocalStorage(elements, appState);
  142. await localFileStorage.saveFiles({
  143. elements,
  144. files,
  145. });
  146. onFilesSaved();
  147. },
  148. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  149. );
  150. const onBlur = () => {
  151. saveDebounced.flush();
  152. };
  153. const initializeScene = async (opts: {
  154. collabAPI: CollabAPI;
  155. }): Promise<
  156. { scene: ImportedDataState | null } & (
  157. | { isExternalScene: true; id: string; key: string }
  158. | { isExternalScene: false; id?: null; key?: null }
  159. )
  160. > => {
  161. const searchParams = new URLSearchParams(window.location.search);
  162. const id = searchParams.get("id");
  163. const jsonBackendMatch = window.location.hash.match(
  164. /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
  165. );
  166. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  167. const localDataState = importFromLocalStorage();
  168. let scene: RestoredDataState & {
  169. scrollToContent?: boolean;
  170. } = await loadScene(null, null, localDataState);
  171. let roomLinkData = getCollaborationLinkData(window.location.href);
  172. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  173. if (isExternalScene) {
  174. if (
  175. // don't prompt if scene is empty
  176. !scene.elements.length ||
  177. // don't prompt for collab scenes because we don't override local storage
  178. roomLinkData ||
  179. // otherwise, prompt whether user wants to override current scene
  180. window.confirm(t("alerts.loadSceneOverridePrompt"))
  181. ) {
  182. if (jsonBackendMatch) {
  183. scene = await loadScene(
  184. jsonBackendMatch[1],
  185. jsonBackendMatch[2],
  186. localDataState,
  187. );
  188. }
  189. scene.scrollToContent = true;
  190. if (!roomLinkData) {
  191. window.history.replaceState({}, APP_NAME, window.location.origin);
  192. }
  193. } else {
  194. // https://github.com/excalidraw/excalidraw/issues/1919
  195. if (document.hidden) {
  196. return new Promise((resolve, reject) => {
  197. window.addEventListener(
  198. "focus",
  199. () => initializeScene(opts).then(resolve).catch(reject),
  200. {
  201. once: true,
  202. },
  203. );
  204. });
  205. }
  206. roomLinkData = null;
  207. window.history.replaceState({}, APP_NAME, window.location.origin);
  208. }
  209. } else if (externalUrlMatch) {
  210. window.history.replaceState({}, APP_NAME, window.location.origin);
  211. const url = externalUrlMatch[1];
  212. try {
  213. const request = await fetch(window.decodeURIComponent(url));
  214. const data = await loadFromBlob(await request.blob(), null, null);
  215. if (
  216. !scene.elements.length ||
  217. window.confirm(t("alerts.loadSceneOverridePrompt"))
  218. ) {
  219. return { scene: data, isExternalScene };
  220. }
  221. } catch (error: any) {
  222. return {
  223. scene: {
  224. appState: {
  225. errorMessage: t("alerts.invalidSceneUrl"),
  226. },
  227. },
  228. isExternalScene,
  229. };
  230. }
  231. }
  232. if (roomLinkData) {
  233. return {
  234. scene: await opts.collabAPI.initializeSocketClient(roomLinkData),
  235. isExternalScene: true,
  236. id: roomLinkData.roomId,
  237. key: roomLinkData.roomKey,
  238. };
  239. } else if (scene) {
  240. return isExternalScene && jsonBackendMatch
  241. ? {
  242. scene,
  243. isExternalScene,
  244. id: jsonBackendMatch[1],
  245. key: jsonBackendMatch[2],
  246. }
  247. : { scene, isExternalScene: false };
  248. }
  249. return { scene: null, isExternalScene: false };
  250. };
  251. const PlusLinkJSX = (
  252. <p style={{ direction: "ltr", unicodeBidi: "embed" }}>
  253. Introducing Excalidraw+
  254. <br />
  255. <a
  256. href="https://plus.excalidraw.com/plus?utm_source=excalidraw&utm_medium=banner&utm_campaign=launch"
  257. target="_blank"
  258. rel="noreferrer"
  259. >
  260. Try out now!
  261. </a>
  262. </p>
  263. );
  264. const ExcalidrawWrapper = () => {
  265. const [errorMessage, setErrorMessage] = useState("");
  266. let currentLangCode = languageDetector.detect() || defaultLang.code;
  267. if (Array.isArray(currentLangCode)) {
  268. currentLangCode = currentLangCode[0];
  269. }
  270. const [langCode, setLangCode] = useState(currentLangCode);
  271. // initial state
  272. // ---------------------------------------------------------------------------
  273. const initialStatePromiseRef = useRef<{
  274. promise: ResolvablePromise<ImportedDataState | null>;
  275. }>({ promise: null! });
  276. if (!initialStatePromiseRef.current.promise) {
  277. initialStatePromiseRef.current.promise =
  278. resolvablePromise<ImportedDataState | null>();
  279. }
  280. useEffect(() => {
  281. // Delayed so that the app has a time to load the latest SW
  282. setTimeout(() => {
  283. trackEvent("load", "version", getVersion());
  284. }, VERSION_TIMEOUT);
  285. }, []);
  286. const [excalidrawAPI, excalidrawRefCallback] =
  287. useCallbackRefState<ExcalidrawImperativeAPI>();
  288. const collabAPI = useContext(CollabContext)?.api;
  289. useEffect(() => {
  290. if (!collabAPI || !excalidrawAPI) {
  291. return;
  292. }
  293. const loadImages = (
  294. data: ResolutionType<typeof initializeScene>,
  295. isInitialLoad = false,
  296. ) => {
  297. if (!data.scene) {
  298. return;
  299. }
  300. if (collabAPI.isCollaborating()) {
  301. if (data.scene.elements) {
  302. collabAPI
  303. .fetchImageFilesFromFirebase({
  304. elements: data.scene.elements,
  305. })
  306. .then(({ loadedFiles, erroredFiles }) => {
  307. excalidrawAPI.addFiles(loadedFiles);
  308. updateStaleImageStatuses({
  309. excalidrawAPI,
  310. erroredFiles,
  311. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  312. });
  313. });
  314. }
  315. } else {
  316. const fileIds =
  317. data.scene.elements?.reduce((acc, element) => {
  318. if (isInitializedImageElement(element)) {
  319. return acc.concat(element.fileId);
  320. }
  321. return acc;
  322. }, [] as FileId[]) || [];
  323. if (data.isExternalScene) {
  324. loadFilesFromFirebase(
  325. `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
  326. data.key,
  327. fileIds,
  328. ).then(({ loadedFiles, erroredFiles }) => {
  329. excalidrawAPI.addFiles(loadedFiles);
  330. updateStaleImageStatuses({
  331. excalidrawAPI,
  332. erroredFiles,
  333. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  334. });
  335. });
  336. } else if (isInitialLoad) {
  337. if (fileIds.length) {
  338. localFileStorage
  339. .getFiles(fileIds)
  340. .then(({ loadedFiles, erroredFiles }) => {
  341. if (loadedFiles.length) {
  342. excalidrawAPI.addFiles(loadedFiles);
  343. }
  344. updateStaleImageStatuses({
  345. excalidrawAPI,
  346. erroredFiles,
  347. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  348. });
  349. });
  350. }
  351. // on fresh load, clear unused files from IDB (from previous
  352. // session)
  353. clearObsoleteFilesFromIndexedDB({ currentFileIds: fileIds });
  354. }
  355. }
  356. data.scene.libraryItems = getLibraryItemsFromStorage();
  357. };
  358. initializeScene({ collabAPI }).then((data) => {
  359. loadImages(data, /* isInitialLoad */ true);
  360. initialStatePromiseRef.current.promise.resolve(data.scene);
  361. });
  362. const onHashChange = (event: HashChangeEvent) => {
  363. event.preventDefault();
  364. const hash = new URLSearchParams(window.location.hash.slice(1));
  365. const libraryUrl = hash.get(URL_HASH_KEYS.addLibrary);
  366. if (libraryUrl) {
  367. // If hash changed and it contains library url, import it and replace
  368. // the url to its previous state (important in case of collaboration
  369. // and similar).
  370. // Using history API won't trigger another hashchange.
  371. window.history.replaceState({}, "", event.oldURL);
  372. excalidrawAPI.importLibrary(libraryUrl, hash.get("token"));
  373. } else {
  374. initializeScene({ collabAPI }).then((data) => {
  375. loadImages(data);
  376. if (data.scene) {
  377. excalidrawAPI.updateScene({
  378. ...data.scene,
  379. appState: restoreAppState(data.scene.appState, null),
  380. });
  381. }
  382. });
  383. }
  384. };
  385. const titleTimeout = setTimeout(
  386. () => (document.title = APP_NAME),
  387. TITLE_TIMEOUT,
  388. );
  389. const syncData = debounce(() => {
  390. if (isTestEnv()) {
  391. return;
  392. }
  393. if (!document.hidden && !collabAPI.isCollaborating()) {
  394. // don't sync if local state is newer or identical to browser state
  395. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
  396. const localDataState = importFromLocalStorage();
  397. const username = importUsernameFromLocalStorage();
  398. let langCode = languageDetector.detect() || defaultLang.code;
  399. if (Array.isArray(langCode)) {
  400. langCode = langCode[0];
  401. }
  402. setLangCode(langCode);
  403. excalidrawAPI.updateScene({
  404. ...localDataState,
  405. libraryItems: getLibraryItemsFromStorage(),
  406. });
  407. collabAPI.setUsername(username || "");
  408. }
  409. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
  410. const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
  411. const currFiles = excalidrawAPI.getFiles();
  412. const fileIds =
  413. elements?.reduce((acc, element) => {
  414. if (
  415. isInitializedImageElement(element) &&
  416. // only load and update images that aren't already loaded
  417. !currFiles[element.fileId]
  418. ) {
  419. return acc.concat(element.fileId);
  420. }
  421. return acc;
  422. }, [] as FileId[]) || [];
  423. if (fileIds.length) {
  424. localFileStorage
  425. .getFiles(fileIds)
  426. .then(({ loadedFiles, erroredFiles }) => {
  427. if (loadedFiles.length) {
  428. excalidrawAPI.addFiles(loadedFiles);
  429. }
  430. updateStaleImageStatuses({
  431. excalidrawAPI,
  432. erroredFiles,
  433. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  434. });
  435. });
  436. }
  437. }
  438. }
  439. }, SYNC_BROWSER_TABS_TIMEOUT);
  440. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  441. window.addEventListener(EVENT.UNLOAD, onBlur, false);
  442. window.addEventListener(EVENT.BLUR, onBlur, false);
  443. document.addEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
  444. window.addEventListener(EVENT.FOCUS, syncData, false);
  445. return () => {
  446. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  447. window.removeEventListener(EVENT.UNLOAD, onBlur, false);
  448. window.removeEventListener(EVENT.BLUR, onBlur, false);
  449. window.removeEventListener(EVENT.FOCUS, syncData, false);
  450. document.removeEventListener(EVENT.VISIBILITY_CHANGE, syncData, false);
  451. clearTimeout(titleTimeout);
  452. };
  453. }, [collabAPI, excalidrawAPI]);
  454. useEffect(() => {
  455. const unloadHandler = (event: BeforeUnloadEvent) => {
  456. saveDebounced.flush();
  457. if (
  458. excalidrawAPI &&
  459. localFileStorage.shouldPreventUnload(excalidrawAPI.getSceneElements())
  460. ) {
  461. preventUnload(event);
  462. }
  463. };
  464. window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  465. return () => {
  466. window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  467. };
  468. }, [excalidrawAPI]);
  469. useEffect(() => {
  470. languageDetector.cacheUserLanguage(langCode);
  471. }, [langCode]);
  472. const onChange = (
  473. elements: readonly ExcalidrawElement[],
  474. appState: AppState,
  475. files: BinaryFiles,
  476. ) => {
  477. if (collabAPI?.isCollaborating()) {
  478. collabAPI.broadcastElements(elements);
  479. } else {
  480. saveDebounced(elements, appState, files, () => {
  481. if (excalidrawAPI) {
  482. let didChange = false;
  483. let pendingImageElement = appState.pendingImageElement;
  484. const elements = excalidrawAPI
  485. .getSceneElementsIncludingDeleted()
  486. .map((element) => {
  487. if (localFileStorage.shouldUpdateImageElementStatus(element)) {
  488. didChange = true;
  489. const newEl = newElementWith(element, { status: "saved" });
  490. if (pendingImageElement === element) {
  491. pendingImageElement = newEl;
  492. }
  493. return newEl;
  494. }
  495. return element;
  496. });
  497. if (didChange) {
  498. excalidrawAPI.updateScene({
  499. elements,
  500. appState: {
  501. pendingImageElement,
  502. },
  503. });
  504. }
  505. }
  506. });
  507. }
  508. };
  509. const onExportToBackend = async (
  510. exportedElements: readonly NonDeletedExcalidrawElement[],
  511. appState: AppState,
  512. files: BinaryFiles,
  513. canvas: HTMLCanvasElement | null,
  514. ) => {
  515. if (exportedElements.length === 0) {
  516. return window.alert(t("alerts.cannotExportEmptyCanvas"));
  517. }
  518. if (canvas) {
  519. try {
  520. await exportToBackend(
  521. exportedElements,
  522. {
  523. ...appState,
  524. viewBackgroundColor: appState.exportBackground
  525. ? appState.viewBackgroundColor
  526. : getDefaultAppState().viewBackgroundColor,
  527. },
  528. files,
  529. );
  530. } catch (error: any) {
  531. if (error.name !== "AbortError") {
  532. const { width, height } = canvas;
  533. console.error(error, { width, height });
  534. setErrorMessage(error.message);
  535. }
  536. }
  537. }
  538. };
  539. const renderTopRightUI = useCallback(
  540. (isMobile: boolean, appState: AppState) => {
  541. if (isMobile) {
  542. return null;
  543. }
  544. return (
  545. <div
  546. style={{
  547. width: "24ch",
  548. fontSize: "0.7em",
  549. textAlign: "center",
  550. }}
  551. >
  552. {/* <GitHubCorner theme={appState.theme} dir={document.dir} /> */}
  553. {/* FIXME remove after 2021-05-20 */}
  554. {PlusLinkJSX}
  555. </div>
  556. );
  557. },
  558. [],
  559. );
  560. const renderFooter = useCallback(
  561. (isMobile: boolean) => {
  562. const renderEncryptedIcon = () => (
  563. <a
  564. className="encrypted-icon tooltip"
  565. href="https://blog.excalidraw.com/end-to-end-encryption/"
  566. target="_blank"
  567. rel="noopener noreferrer"
  568. aria-label={t("encrypted.link")}
  569. >
  570. <Tooltip label={t("encrypted.tooltip")} long={true}>
  571. {shield}
  572. </Tooltip>
  573. </a>
  574. );
  575. const renderLanguageList = () => (
  576. <LanguageList
  577. onChange={(langCode) => setLangCode(langCode)}
  578. languages={languages}
  579. currentLangCode={langCode}
  580. />
  581. );
  582. if (isMobile) {
  583. const isTinyDevice = window.innerWidth < 362;
  584. return (
  585. <div
  586. style={{
  587. display: "flex",
  588. flexDirection: isTinyDevice ? "column" : "row",
  589. }}
  590. >
  591. <fieldset>
  592. <legend>{t("labels.language")}</legend>
  593. {renderLanguageList()}
  594. </fieldset>
  595. {/* FIXME remove after 2021-05-20 */}
  596. <div
  597. style={{
  598. width: "24ch",
  599. fontSize: "0.7em",
  600. textAlign: "center",
  601. marginTop: isTinyDevice ? 16 : undefined,
  602. marginLeft: "auto",
  603. marginRight: isTinyDevice ? "auto" : undefined,
  604. padding: "4px 2px",
  605. border: "1px dashed #aaa",
  606. borderRadius: 12,
  607. }}
  608. >
  609. {PlusLinkJSX}
  610. </div>
  611. </div>
  612. );
  613. }
  614. return (
  615. <>
  616. {renderEncryptedIcon()}
  617. {renderLanguageList()}
  618. </>
  619. );
  620. },
  621. [langCode],
  622. );
  623. const renderCustomStats = () => {
  624. return (
  625. <CustomStats
  626. setToastMessage={(message) => excalidrawAPI!.setToastMessage(message)}
  627. />
  628. );
  629. };
  630. const onLibraryChange = async (items: LibraryItems) => {
  631. if (!items.length) {
  632. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  633. return;
  634. }
  635. const serializedItems = JSON.stringify(items);
  636. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  637. };
  638. const onRoomClose = useCallback(() => {
  639. localFileStorage.reset();
  640. }, []);
  641. return (
  642. <>
  643. <Excalidraw
  644. ref={excalidrawRefCallback}
  645. onChange={onChange}
  646. initialData={initialStatePromiseRef.current.promise}
  647. onCollabButtonClick={collabAPI?.onCollabButtonClick}
  648. isCollaborating={collabAPI?.isCollaborating()}
  649. onPointerUpdate={collabAPI?.onPointerUpdate}
  650. UIOptions={{
  651. canvasActions: {
  652. export: {
  653. onExportToBackend,
  654. renderCustomUI: (elements, appState, files) => {
  655. return (
  656. <ExportToExcalidrawPlus
  657. elements={elements}
  658. appState={appState}
  659. files={files}
  660. onError={(error) => {
  661. excalidrawAPI?.updateScene({
  662. appState: {
  663. errorMessage: error.message,
  664. },
  665. });
  666. }}
  667. />
  668. );
  669. },
  670. },
  671. },
  672. }}
  673. renderTopRightUI={renderTopRightUI}
  674. renderFooter={renderFooter}
  675. langCode={langCode}
  676. renderCustomStats={renderCustomStats}
  677. detectScroll={false}
  678. handleKeyboardGlobally={true}
  679. onLibraryChange={onLibraryChange}
  680. autoFocus={true}
  681. />
  682. {excalidrawAPI && (
  683. <CollabWrapper
  684. excalidrawAPI={excalidrawAPI}
  685. onRoomClose={onRoomClose}
  686. />
  687. )}
  688. {errorMessage && (
  689. <ErrorDialog
  690. message={errorMessage}
  691. onClose={() => setErrorMessage("")}
  692. />
  693. )}
  694. </>
  695. );
  696. };
  697. const ExcalidrawApp = () => {
  698. return (
  699. <TopErrorBoundary>
  700. <CollabContextConsumer>
  701. <ExcalidrawWrapper />
  702. </CollabContextConsumer>
  703. </TopErrorBoundary>
  704. );
  705. };
  706. export default ExcalidrawApp;