index.tsx 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  1. import polyfill from "../src/polyfill";
  2. import LanguageDetector from "i18next-browser-languagedetector";
  3. import { useEffect, useRef, useState } from "react";
  4. import { trackEvent } from "../src/analytics";
  5. import { getDefaultAppState } from "../src/appState";
  6. import { ErrorDialog } from "../src/components/ErrorDialog";
  7. import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
  8. import {
  9. APP_NAME,
  10. EVENT,
  11. THEME,
  12. TITLE_TIMEOUT,
  13. VERSION_TIMEOUT,
  14. } from "../src/constants";
  15. import { loadFromBlob } from "../src/data/blob";
  16. import {
  17. ExcalidrawElement,
  18. FileId,
  19. NonDeletedExcalidrawElement,
  20. Theme,
  21. } from "../src/element/types";
  22. import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
  23. import { t } from "../src/i18n";
  24. import {
  25. Excalidraw,
  26. defaultLang,
  27. LiveCollaborationTrigger,
  28. } from "../src/packages/excalidraw/index";
  29. import {
  30. AppState,
  31. LibraryItems,
  32. ExcalidrawImperativeAPI,
  33. BinaryFiles,
  34. ExcalidrawInitialDataState,
  35. UIAppState,
  36. } from "../src/types";
  37. import {
  38. debounce,
  39. getVersion,
  40. getFrame,
  41. isTestEnv,
  42. preventUnload,
  43. ResolvablePromise,
  44. resolvablePromise,
  45. isRunningInIframe,
  46. } from "../src/utils";
  47. import {
  48. FIREBASE_STORAGE_PREFIXES,
  49. STORAGE_KEYS,
  50. SYNC_BROWSER_TABS_TIMEOUT,
  51. } from "./app_constants";
  52. import Collab, {
  53. CollabAPI,
  54. collabAPIAtom,
  55. collabDialogShownAtom,
  56. isCollaboratingAtom,
  57. isOfflineAtom,
  58. } from "./collab/Collab";
  59. import {
  60. exportToBackend,
  61. getCollaborationLinkData,
  62. isCollaborationLink,
  63. loadScene,
  64. } from "./data";
  65. import {
  66. getLibraryItemsFromStorage,
  67. importFromLocalStorage,
  68. importUsernameFromLocalStorage,
  69. } from "./data/localStorage";
  70. import CustomStats from "./CustomStats";
  71. import {
  72. restore,
  73. restoreAppState,
  74. RestoredDataState,
  75. } from "../src/data/restore";
  76. import {
  77. ExportToExcalidrawPlus,
  78. exportToExcalidrawPlus,
  79. } from "./components/ExportToExcalidrawPlus";
  80. import { updateStaleImageStatuses } from "./data/FileManager";
  81. import { newElementWith } from "../src/element/mutateElement";
  82. import { isInitializedImageElement } from "../src/element/typeChecks";
  83. import { loadFilesFromFirebase } from "./data/firebase";
  84. import { LocalData } from "./data/LocalData";
  85. import { isBrowserStorageStateNewer } from "./data/tabSync";
  86. import clsx from "clsx";
  87. import { reconcileElements } from "./collab/reconciliation";
  88. import {
  89. parseLibraryTokensFromUrl,
  90. useHandleLibrary,
  91. } from "../src/data/library";
  92. import { AppMainMenu } from "./components/AppMainMenu";
  93. import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
  94. import { AppFooter } from "./components/AppFooter";
  95. import { atom, Provider, useAtom, useAtomValue } from "jotai";
  96. import { useAtomWithInitialValue } from "../src/jotai";
  97. import { appJotaiStore } from "./app-jotai";
  98. import "./index.scss";
  99. import { ResolutionType } from "../src/utility-types";
  100. import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
  101. import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
  102. import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
  103. import Trans from "../src/components/Trans";
  104. polyfill();
  105. window.EXCALIDRAW_THROTTLE_RENDER = true;
  106. let isSelfEmbedding = false;
  107. if (window.self !== window.top) {
  108. try {
  109. const parentUrl = new URL(document.referrer);
  110. const currentUrl = new URL(window.location.href);
  111. if (parentUrl.origin === currentUrl.origin) {
  112. isSelfEmbedding = true;
  113. }
  114. } catch (error) {
  115. // ignore
  116. }
  117. }
  118. const languageDetector = new LanguageDetector();
  119. languageDetector.init({
  120. languageUtils: {},
  121. });
  122. const shareableLinkConfirmDialog = {
  123. title: t("overwriteConfirm.modal.shareableLink.title"),
  124. description: (
  125. <Trans
  126. i18nKey="overwriteConfirm.modal.shareableLink.description"
  127. bold={(text) => <strong>{text}</strong>}
  128. br={() => <br />}
  129. />
  130. ),
  131. actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
  132. color: "danger",
  133. } as const;
  134. const initializeScene = async (opts: {
  135. collabAPI: CollabAPI | null;
  136. excalidrawAPI: ExcalidrawImperativeAPI;
  137. }): Promise<
  138. { scene: ExcalidrawInitialDataState | null } & (
  139. | { isExternalScene: true; id: string; key: string }
  140. | { isExternalScene: false; id?: null; key?: null }
  141. )
  142. > => {
  143. const searchParams = new URLSearchParams(window.location.search);
  144. const id = searchParams.get("id");
  145. const jsonBackendMatch = window.location.hash.match(
  146. /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
  147. );
  148. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  149. const localDataState = importFromLocalStorage();
  150. let scene: RestoredDataState & {
  151. scrollToContent?: boolean;
  152. } = await loadScene(null, null, localDataState);
  153. let roomLinkData = getCollaborationLinkData(window.location.href);
  154. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  155. if (isExternalScene) {
  156. if (
  157. // don't prompt if scene is empty
  158. !scene.elements.length ||
  159. // don't prompt for collab scenes because we don't override local storage
  160. roomLinkData ||
  161. // otherwise, prompt whether user wants to override current scene
  162. (await openConfirmModal(shareableLinkConfirmDialog))
  163. ) {
  164. if (jsonBackendMatch) {
  165. scene = await loadScene(
  166. jsonBackendMatch[1],
  167. jsonBackendMatch[2],
  168. localDataState,
  169. );
  170. }
  171. scene.scrollToContent = true;
  172. if (!roomLinkData) {
  173. window.history.replaceState({}, APP_NAME, window.location.origin);
  174. }
  175. } else {
  176. // https://github.com/excalidraw/excalidraw/issues/1919
  177. if (document.hidden) {
  178. return new Promise((resolve, reject) => {
  179. window.addEventListener(
  180. "focus",
  181. () => initializeScene(opts).then(resolve).catch(reject),
  182. {
  183. once: true,
  184. },
  185. );
  186. });
  187. }
  188. roomLinkData = null;
  189. window.history.replaceState({}, APP_NAME, window.location.origin);
  190. }
  191. } else if (externalUrlMatch) {
  192. window.history.replaceState({}, APP_NAME, window.location.origin);
  193. const url = externalUrlMatch[1];
  194. try {
  195. const request = await fetch(window.decodeURIComponent(url));
  196. const data = await loadFromBlob(await request.blob(), null, null);
  197. if (
  198. !scene.elements.length ||
  199. (await openConfirmModal(shareableLinkConfirmDialog))
  200. ) {
  201. return { scene: data, isExternalScene };
  202. }
  203. } catch (error: any) {
  204. return {
  205. scene: {
  206. appState: {
  207. errorMessage: t("alerts.invalidSceneUrl"),
  208. },
  209. },
  210. isExternalScene,
  211. };
  212. }
  213. }
  214. if (roomLinkData && opts.collabAPI) {
  215. const { excalidrawAPI } = opts;
  216. const scene = await opts.collabAPI.startCollaboration(roomLinkData);
  217. return {
  218. // when collaborating, the state may have already been updated at this
  219. // point (we may have received updates from other clients), so reconcile
  220. // elements and appState with existing state
  221. scene: {
  222. ...scene,
  223. appState: {
  224. ...restoreAppState(
  225. {
  226. ...scene?.appState,
  227. theme: localDataState?.appState?.theme || scene?.appState?.theme,
  228. },
  229. excalidrawAPI.getAppState(),
  230. ),
  231. // necessary if we're invoking from a hashchange handler which doesn't
  232. // go through App.initializeScene() that resets this flag
  233. isLoading: false,
  234. },
  235. elements: reconcileElements(
  236. scene?.elements || [],
  237. excalidrawAPI.getSceneElementsIncludingDeleted(),
  238. excalidrawAPI.getAppState(),
  239. ),
  240. },
  241. isExternalScene: true,
  242. id: roomLinkData.roomId,
  243. key: roomLinkData.roomKey,
  244. };
  245. } else if (scene) {
  246. return isExternalScene && jsonBackendMatch
  247. ? {
  248. scene,
  249. isExternalScene,
  250. id: jsonBackendMatch[1],
  251. key: jsonBackendMatch[2],
  252. }
  253. : { scene, isExternalScene: false };
  254. }
  255. return { scene: null, isExternalScene: false };
  256. };
  257. const detectedLangCode = languageDetector.detect() || defaultLang.code;
  258. export const appLangCodeAtom = atom(
  259. Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
  260. );
  261. const ExcalidrawWrapper = () => {
  262. const [errorMessage, setErrorMessage] = useState("");
  263. const [langCode, setLangCode] = useAtom(appLangCodeAtom);
  264. const isCollabDisabled = isRunningInIframe();
  265. // initial state
  266. // ---------------------------------------------------------------------------
  267. const initialStatePromiseRef = useRef<{
  268. promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
  269. }>({ promise: null! });
  270. if (!initialStatePromiseRef.current.promise) {
  271. initialStatePromiseRef.current.promise =
  272. resolvablePromise<ExcalidrawInitialDataState | null>();
  273. }
  274. useEffect(() => {
  275. trackEvent("load", "frame", getFrame());
  276. // Delayed so that the app has a time to load the latest SW
  277. setTimeout(() => {
  278. trackEvent("load", "version", getVersion());
  279. }, VERSION_TIMEOUT);
  280. }, []);
  281. const [excalidrawAPI, excalidrawRefCallback] =
  282. useCallbackRefState<ExcalidrawImperativeAPI>();
  283. const [collabAPI] = useAtom(collabAPIAtom);
  284. const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
  285. const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
  286. return isCollaborationLink(window.location.href);
  287. });
  288. useHandleLibrary({
  289. excalidrawAPI,
  290. getInitialLibraryItems: getLibraryItemsFromStorage,
  291. });
  292. useEffect(() => {
  293. if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
  294. return;
  295. }
  296. const loadImages = (
  297. data: ResolutionType<typeof initializeScene>,
  298. isInitialLoad = false,
  299. ) => {
  300. if (!data.scene) {
  301. return;
  302. }
  303. if (collabAPI?.isCollaborating()) {
  304. if (data.scene.elements) {
  305. collabAPI
  306. .fetchImageFilesFromFirebase({
  307. elements: data.scene.elements,
  308. forceFetchFiles: true,
  309. })
  310. .then(({ loadedFiles, erroredFiles }) => {
  311. excalidrawAPI.addFiles(loadedFiles);
  312. updateStaleImageStatuses({
  313. excalidrawAPI,
  314. erroredFiles,
  315. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  316. });
  317. });
  318. }
  319. } else {
  320. const fileIds =
  321. data.scene.elements?.reduce((acc, element) => {
  322. if (isInitializedImageElement(element)) {
  323. return acc.concat(element.fileId);
  324. }
  325. return acc;
  326. }, [] as FileId[]) || [];
  327. if (data.isExternalScene) {
  328. loadFilesFromFirebase(
  329. `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
  330. data.key,
  331. fileIds,
  332. ).then(({ loadedFiles, erroredFiles }) => {
  333. excalidrawAPI.addFiles(loadedFiles);
  334. updateStaleImageStatuses({
  335. excalidrawAPI,
  336. erroredFiles,
  337. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  338. });
  339. });
  340. } else if (isInitialLoad) {
  341. if (fileIds.length) {
  342. LocalData.fileStorage
  343. .getFiles(fileIds)
  344. .then(({ loadedFiles, erroredFiles }) => {
  345. if (loadedFiles.length) {
  346. excalidrawAPI.addFiles(loadedFiles);
  347. }
  348. updateStaleImageStatuses({
  349. excalidrawAPI,
  350. erroredFiles,
  351. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  352. });
  353. });
  354. }
  355. // on fresh load, clear unused files from IDB (from previous
  356. // session)
  357. LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
  358. }
  359. }
  360. };
  361. initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
  362. loadImages(data, /* isInitialLoad */ true);
  363. initialStatePromiseRef.current.promise.resolve(data.scene);
  364. });
  365. const onHashChange = async (event: HashChangeEvent) => {
  366. event.preventDefault();
  367. const libraryUrlTokens = parseLibraryTokensFromUrl();
  368. if (!libraryUrlTokens) {
  369. if (
  370. collabAPI?.isCollaborating() &&
  371. !isCollaborationLink(window.location.href)
  372. ) {
  373. collabAPI.stopCollaboration(false);
  374. }
  375. excalidrawAPI.updateScene({ appState: { isLoading: true } });
  376. initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
  377. loadImages(data);
  378. if (data.scene) {
  379. excalidrawAPI.updateScene({
  380. ...data.scene,
  381. ...restore(data.scene, null, null, { repairBindings: true }),
  382. commitToHistory: true,
  383. });
  384. }
  385. });
  386. }
  387. };
  388. const titleTimeout = setTimeout(
  389. () => (document.title = APP_NAME),
  390. TITLE_TIMEOUT,
  391. );
  392. const syncData = debounce(() => {
  393. if (isTestEnv()) {
  394. return;
  395. }
  396. if (
  397. !document.hidden &&
  398. ((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
  399. ) {
  400. // don't sync if local state is newer or identical to browser state
  401. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
  402. const localDataState = importFromLocalStorage();
  403. const username = importUsernameFromLocalStorage();
  404. let langCode = languageDetector.detect() || defaultLang.code;
  405. if (Array.isArray(langCode)) {
  406. langCode = langCode[0];
  407. }
  408. setLangCode(langCode);
  409. excalidrawAPI.updateScene({
  410. ...localDataState,
  411. });
  412. excalidrawAPI.updateLibrary({
  413. libraryItems: getLibraryItemsFromStorage(),
  414. });
  415. collabAPI?.setUsername(username || "");
  416. }
  417. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
  418. const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
  419. const currFiles = excalidrawAPI.getFiles();
  420. const fileIds =
  421. elements?.reduce((acc, element) => {
  422. if (
  423. isInitializedImageElement(element) &&
  424. // only load and update images that aren't already loaded
  425. !currFiles[element.fileId]
  426. ) {
  427. return acc.concat(element.fileId);
  428. }
  429. return acc;
  430. }, [] as FileId[]) || [];
  431. if (fileIds.length) {
  432. LocalData.fileStorage
  433. .getFiles(fileIds)
  434. .then(({ loadedFiles, erroredFiles }) => {
  435. if (loadedFiles.length) {
  436. excalidrawAPI.addFiles(loadedFiles);
  437. }
  438. updateStaleImageStatuses({
  439. excalidrawAPI,
  440. erroredFiles,
  441. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  442. });
  443. });
  444. }
  445. }
  446. }
  447. }, SYNC_BROWSER_TABS_TIMEOUT);
  448. const onUnload = () => {
  449. LocalData.flushSave();
  450. };
  451. const visibilityChange = (event: FocusEvent | Event) => {
  452. if (event.type === EVENT.BLUR || document.hidden) {
  453. LocalData.flushSave();
  454. }
  455. if (
  456. event.type === EVENT.VISIBILITY_CHANGE ||
  457. event.type === EVENT.FOCUS
  458. ) {
  459. syncData();
  460. }
  461. };
  462. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  463. window.addEventListener(EVENT.UNLOAD, onUnload, false);
  464. window.addEventListener(EVENT.BLUR, visibilityChange, false);
  465. document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
  466. window.addEventListener(EVENT.FOCUS, visibilityChange, false);
  467. return () => {
  468. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  469. window.removeEventListener(EVENT.UNLOAD, onUnload, false);
  470. window.removeEventListener(EVENT.BLUR, visibilityChange, false);
  471. window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
  472. document.removeEventListener(
  473. EVENT.VISIBILITY_CHANGE,
  474. visibilityChange,
  475. false,
  476. );
  477. clearTimeout(titleTimeout);
  478. };
  479. }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
  480. useEffect(() => {
  481. const unloadHandler = (event: BeforeUnloadEvent) => {
  482. LocalData.flushSave();
  483. if (
  484. excalidrawAPI &&
  485. LocalData.fileStorage.shouldPreventUnload(
  486. excalidrawAPI.getSceneElements(),
  487. )
  488. ) {
  489. preventUnload(event);
  490. }
  491. };
  492. window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  493. return () => {
  494. window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  495. };
  496. }, [excalidrawAPI]);
  497. useEffect(() => {
  498. languageDetector.cacheUserLanguage(langCode);
  499. }, [langCode]);
  500. const [theme, setTheme] = useState<Theme>(
  501. () =>
  502. (localStorage.getItem(
  503. STORAGE_KEYS.LOCAL_STORAGE_THEME,
  504. ) as Theme | null) ||
  505. // FIXME migration from old LS scheme. Can be removed later. #5660
  506. importFromLocalStorage().appState?.theme ||
  507. THEME.LIGHT,
  508. );
  509. useEffect(() => {
  510. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
  511. // currently only used for body styling during init (see public/index.html),
  512. // but may change in the future
  513. document.documentElement.classList.toggle("dark", theme === THEME.DARK);
  514. }, [theme]);
  515. const onChange = (
  516. elements: readonly ExcalidrawElement[],
  517. appState: AppState,
  518. files: BinaryFiles,
  519. ) => {
  520. if (collabAPI?.isCollaborating()) {
  521. collabAPI.syncElements(elements);
  522. }
  523. setTheme(appState.theme);
  524. // this check is redundant, but since this is a hot path, it's best
  525. // not to evaludate the nested expression every time
  526. if (!LocalData.isSavePaused()) {
  527. LocalData.save(elements, appState, files, () => {
  528. if (excalidrawAPI) {
  529. let didChange = false;
  530. const elements = excalidrawAPI
  531. .getSceneElementsIncludingDeleted()
  532. .map((element) => {
  533. if (
  534. LocalData.fileStorage.shouldUpdateImageElementStatus(element)
  535. ) {
  536. const newElement = newElementWith(element, { status: "saved" });
  537. if (newElement !== element) {
  538. didChange = true;
  539. }
  540. return newElement;
  541. }
  542. return element;
  543. });
  544. if (didChange) {
  545. excalidrawAPI.updateScene({
  546. elements,
  547. });
  548. }
  549. }
  550. });
  551. }
  552. };
  553. const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
  554. null,
  555. );
  556. const onExportToBackend = async (
  557. exportedElements: readonly NonDeletedExcalidrawElement[],
  558. appState: Partial<AppState>,
  559. files: BinaryFiles,
  560. canvas: HTMLCanvasElement,
  561. ) => {
  562. if (exportedElements.length === 0) {
  563. throw new Error(t("alerts.cannotExportEmptyCanvas"));
  564. }
  565. if (canvas) {
  566. try {
  567. const { url, errorMessage } = await exportToBackend(
  568. exportedElements,
  569. {
  570. ...appState,
  571. viewBackgroundColor: appState.exportBackground
  572. ? appState.viewBackgroundColor
  573. : getDefaultAppState().viewBackgroundColor,
  574. },
  575. files,
  576. );
  577. if (errorMessage) {
  578. throw new Error(errorMessage);
  579. }
  580. if (url) {
  581. setLatestShareableLink(url);
  582. }
  583. } catch (error: any) {
  584. if (error.name !== "AbortError") {
  585. const { width, height } = canvas;
  586. console.error(error, { width, height });
  587. throw new Error(error.message);
  588. }
  589. }
  590. }
  591. };
  592. const renderCustomStats = (
  593. elements: readonly NonDeletedExcalidrawElement[],
  594. appState: UIAppState,
  595. ) => {
  596. return (
  597. <CustomStats
  598. setToast={(message) => excalidrawAPI!.setToast({ message })}
  599. appState={appState}
  600. elements={elements}
  601. />
  602. );
  603. };
  604. const onLibraryChange = async (items: LibraryItems) => {
  605. if (!items.length) {
  606. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  607. return;
  608. }
  609. const serializedItems = JSON.stringify(items);
  610. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  611. };
  612. const isOffline = useAtomValue(isOfflineAtom);
  613. // browsers generally prevent infinite self-embedding, there are
  614. // cases where it still happens, and while we disallow self-embedding
  615. // by not whitelisting our own origin, this serves as an additional guard
  616. if (isSelfEmbedding) {
  617. return (
  618. <div
  619. style={{
  620. display: "flex",
  621. alignItems: "center",
  622. justifyContent: "center",
  623. textAlign: "center",
  624. height: "100%",
  625. }}
  626. >
  627. <h1>I'm not a pretzel!</h1>
  628. </div>
  629. );
  630. }
  631. return (
  632. <div
  633. style={{ height: "100%" }}
  634. className={clsx("excalidraw-app", {
  635. "is-collaborating": isCollaborating,
  636. })}
  637. >
  638. <Excalidraw
  639. ref={excalidrawRefCallback}
  640. onChange={onChange}
  641. initialData={initialStatePromiseRef.current.promise}
  642. isCollaborating={isCollaborating}
  643. onPointerUpdate={collabAPI?.onPointerUpdate}
  644. UIOptions={{
  645. canvasActions: {
  646. toggleTheme: true,
  647. export: {
  648. onExportToBackend,
  649. renderCustomUI: (elements, appState, files) => {
  650. return (
  651. <ExportToExcalidrawPlus
  652. elements={elements}
  653. appState={appState}
  654. files={files}
  655. onError={(error) => {
  656. excalidrawAPI?.updateScene({
  657. appState: {
  658. errorMessage: error.message,
  659. },
  660. });
  661. }}
  662. onSuccess={() => {
  663. excalidrawAPI?.updateScene({
  664. appState: { openDialog: null },
  665. });
  666. }}
  667. />
  668. );
  669. },
  670. },
  671. },
  672. }}
  673. langCode={langCode}
  674. renderCustomStats={renderCustomStats}
  675. detectScroll={false}
  676. handleKeyboardGlobally={true}
  677. onLibraryChange={onLibraryChange}
  678. autoFocus={true}
  679. theme={theme}
  680. renderTopRightUI={(isMobile) => {
  681. if (isMobile || !collabAPI || isCollabDisabled) {
  682. return null;
  683. }
  684. return (
  685. <LiveCollaborationTrigger
  686. isCollaborating={isCollaborating}
  687. onSelect={() => setCollabDialogShown(true)}
  688. />
  689. );
  690. }}
  691. >
  692. <AppMainMenu
  693. setCollabDialogShown={setCollabDialogShown}
  694. isCollaborating={isCollaborating}
  695. isCollabEnabled={!isCollabDisabled}
  696. />
  697. <AppWelcomeScreen
  698. setCollabDialogShown={setCollabDialogShown}
  699. isCollabEnabled={!isCollabDisabled}
  700. />
  701. <OverwriteConfirmDialog>
  702. <OverwriteConfirmDialog.Actions.ExportToImage />
  703. <OverwriteConfirmDialog.Actions.SaveToDisk />
  704. {excalidrawAPI && (
  705. <OverwriteConfirmDialog.Action
  706. title={t("overwriteConfirm.action.excalidrawPlus.title")}
  707. actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
  708. onClick={() => {
  709. exportToExcalidrawPlus(
  710. excalidrawAPI.getSceneElements(),
  711. excalidrawAPI.getAppState(),
  712. excalidrawAPI.getFiles(),
  713. );
  714. }}
  715. >
  716. {t("overwriteConfirm.action.excalidrawPlus.description")}
  717. </OverwriteConfirmDialog.Action>
  718. )}
  719. </OverwriteConfirmDialog>
  720. <AppFooter />
  721. {isCollaborating && isOffline && (
  722. <div className="collab-offline-warning">
  723. {t("alerts.collabOfflineWarning")}
  724. </div>
  725. )}
  726. {latestShareableLink && (
  727. <ShareableLinkDialog
  728. link={latestShareableLink}
  729. onCloseRequest={() => setLatestShareableLink(null)}
  730. setErrorMessage={setErrorMessage}
  731. />
  732. )}
  733. {excalidrawAPI && !isCollabDisabled && (
  734. <Collab excalidrawAPI={excalidrawAPI} />
  735. )}
  736. {errorMessage && (
  737. <ErrorDialog onClose={() => setErrorMessage("")}>
  738. {errorMessage}
  739. </ErrorDialog>
  740. )}
  741. </Excalidraw>
  742. </div>
  743. );
  744. };
  745. const ExcalidrawApp = () => {
  746. return (
  747. <TopErrorBoundary>
  748. <Provider unstable_createStore={() => appJotaiStore}>
  749. <ExcalidrawWrapper />
  750. </Provider>
  751. </TopErrorBoundary>
  752. );
  753. };
  754. export default ExcalidrawApp;