index.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812
  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. commitToStore: 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. skipSnapshotUpdate: true,
  548. });
  549. }
  550. }
  551. });
  552. }
  553. };
  554. const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
  555. null,
  556. );
  557. const onExportToBackend = async (
  558. exportedElements: readonly NonDeletedExcalidrawElement[],
  559. appState: Partial<AppState>,
  560. files: BinaryFiles,
  561. canvas: HTMLCanvasElement,
  562. ) => {
  563. if (exportedElements.length === 0) {
  564. throw new Error(t("alerts.cannotExportEmptyCanvas"));
  565. }
  566. if (canvas) {
  567. try {
  568. const { url, errorMessage } = await exportToBackend(
  569. exportedElements,
  570. {
  571. ...appState,
  572. viewBackgroundColor: appState.exportBackground
  573. ? appState.viewBackgroundColor
  574. : getDefaultAppState().viewBackgroundColor,
  575. },
  576. files,
  577. );
  578. if (errorMessage) {
  579. throw new Error(errorMessage);
  580. }
  581. if (url) {
  582. setLatestShareableLink(url);
  583. }
  584. } catch (error: any) {
  585. if (error.name !== "AbortError") {
  586. const { width, height } = canvas;
  587. console.error(error, { width, height });
  588. throw new Error(error.message);
  589. }
  590. }
  591. }
  592. };
  593. const renderCustomStats = (
  594. elements: readonly NonDeletedExcalidrawElement[],
  595. appState: UIAppState,
  596. ) => {
  597. return (
  598. <CustomStats
  599. setToast={(message) => excalidrawAPI!.setToast({ message })}
  600. appState={appState}
  601. elements={elements}
  602. />
  603. );
  604. };
  605. const onLibraryChange = async (items: LibraryItems) => {
  606. if (!items.length) {
  607. localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
  608. return;
  609. }
  610. const serializedItems = JSON.stringify(items);
  611. localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
  612. };
  613. const isOffline = useAtomValue(isOfflineAtom);
  614. // browsers generally prevent infinite self-embedding, there are
  615. // cases where it still happens, and while we disallow self-embedding
  616. // by not whitelisting our own origin, this serves as an additional guard
  617. if (isSelfEmbedding) {
  618. return (
  619. <div
  620. style={{
  621. display: "flex",
  622. alignItems: "center",
  623. justifyContent: "center",
  624. textAlign: "center",
  625. height: "100%",
  626. }}
  627. >
  628. <h1>I'm not a pretzel!</h1>
  629. </div>
  630. );
  631. }
  632. return (
  633. <div
  634. style={{ height: "100%" }}
  635. className={clsx("excalidraw-app", {
  636. "is-collaborating": isCollaborating,
  637. })}
  638. >
  639. <Excalidraw
  640. excalidrawAPI={excalidrawRefCallback}
  641. onChange={onChange}
  642. initialData={initialStatePromiseRef.current.promise}
  643. isCollaborating={isCollaborating}
  644. onPointerUpdate={collabAPI?.onPointerUpdate}
  645. UIOptions={{
  646. canvasActions: {
  647. toggleTheme: true,
  648. export: {
  649. onExportToBackend,
  650. renderCustomUI: (elements, appState, files) => {
  651. return (
  652. <ExportToExcalidrawPlus
  653. elements={elements}
  654. appState={appState}
  655. files={files}
  656. onError={(error) => {
  657. excalidrawAPI?.updateScene({
  658. appState: {
  659. errorMessage: error.message,
  660. },
  661. });
  662. }}
  663. onSuccess={() => {
  664. excalidrawAPI?.updateScene({
  665. appState: { openDialog: null },
  666. });
  667. }}
  668. />
  669. );
  670. },
  671. },
  672. },
  673. }}
  674. langCode={langCode}
  675. renderCustomStats={renderCustomStats}
  676. detectScroll={false}
  677. handleKeyboardGlobally={true}
  678. onLibraryChange={onLibraryChange}
  679. autoFocus={true}
  680. theme={theme}
  681. renderTopRightUI={(isMobile) => {
  682. if (isMobile || !collabAPI || isCollabDisabled) {
  683. return null;
  684. }
  685. return (
  686. <LiveCollaborationTrigger
  687. isCollaborating={isCollaborating}
  688. onSelect={() => setCollabDialogShown(true)}
  689. />
  690. );
  691. }}
  692. >
  693. <AppMainMenu
  694. setCollabDialogShown={setCollabDialogShown}
  695. isCollaborating={isCollaborating}
  696. isCollabEnabled={!isCollabDisabled}
  697. />
  698. <AppWelcomeScreen
  699. setCollabDialogShown={setCollabDialogShown}
  700. isCollabEnabled={!isCollabDisabled}
  701. />
  702. <OverwriteConfirmDialog>
  703. <OverwriteConfirmDialog.Actions.ExportToImage />
  704. <OverwriteConfirmDialog.Actions.SaveToDisk />
  705. {excalidrawAPI && (
  706. <OverwriteConfirmDialog.Action
  707. title={t("overwriteConfirm.action.excalidrawPlus.title")}
  708. actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
  709. onClick={() => {
  710. exportToExcalidrawPlus(
  711. excalidrawAPI.getSceneElements(),
  712. excalidrawAPI.getAppState(),
  713. excalidrawAPI.getFiles(),
  714. );
  715. }}
  716. >
  717. {t("overwriteConfirm.action.excalidrawPlus.description")}
  718. </OverwriteConfirmDialog.Action>
  719. )}
  720. </OverwriteConfirmDialog>
  721. <AppFooter />
  722. {isCollaborating && isOffline && (
  723. <div className="collab-offline-warning">
  724. {t("alerts.collabOfflineWarning")}
  725. </div>
  726. )}
  727. {latestShareableLink && (
  728. <ShareableLinkDialog
  729. link={latestShareableLink}
  730. onCloseRequest={() => setLatestShareableLink(null)}
  731. setErrorMessage={setErrorMessage}
  732. />
  733. )}
  734. {excalidrawAPI && !isCollabDisabled && (
  735. <Collab excalidrawAPI={excalidrawAPI} />
  736. )}
  737. {errorMessage && (
  738. <ErrorDialog onClose={() => setErrorMessage("")}>
  739. {errorMessage}
  740. </ErrorDialog>
  741. )}
  742. </Excalidraw>
  743. </div>
  744. );
  745. };
  746. const ExcalidrawApp = () => {
  747. return (
  748. <TopErrorBoundary>
  749. <Provider unstable_createStore={() => appJotaiStore}>
  750. <ExcalidrawWrapper />
  751. </Provider>
  752. </TopErrorBoundary>
  753. );
  754. };
  755. export default ExcalidrawApp;