App.tsx 27 KB

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