2
0

App.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107
  1. import polyfill from "../packages/excalidraw/polyfill";
  2. import LanguageDetector from "i18next-browser-languagedetector";
  3. import { useCallback, 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. FileId,
  18. NonDeletedExcalidrawElement,
  19. OrderedExcalidrawElement,
  20. } from "../packages/excalidraw/element/types";
  21. import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
  22. import { t } from "../packages/excalidraw/i18n";
  23. import {
  24. Excalidraw,
  25. defaultLang,
  26. LiveCollaborationTrigger,
  27. TTDDialog,
  28. TTDDialogTrigger,
  29. } from "../packages/excalidraw/index";
  30. import {
  31. AppState,
  32. ExcalidrawImperativeAPI,
  33. BinaryFiles,
  34. ExcalidrawInitialDataState,
  35. UIAppState,
  36. } from "../packages/excalidraw/types";
  37. import {
  38. debounce,
  39. getVersion,
  40. getFrame,
  41. isTestEnv,
  42. preventUnload,
  43. ResolvablePromise,
  44. resolvablePromise,
  45. isRunningInIframe,
  46. } from "../packages/excalidraw/utils";
  47. import {
  48. FIREBASE_STORAGE_PREFIXES,
  49. isExcalidrawPlusSignedUser,
  50. STORAGE_KEYS,
  51. SYNC_BROWSER_TABS_TIMEOUT,
  52. } from "./app_constants";
  53. import Collab, {
  54. CollabAPI,
  55. collabAPIAtom,
  56. isCollaboratingAtom,
  57. isOfflineAtom,
  58. } from "./collab/Collab";
  59. import {
  60. exportToBackend,
  61. getCollaborationLinkData,
  62. isCollaborationLink,
  63. loadScene,
  64. } from "./data";
  65. import {
  66. importFromLocalStorage,
  67. importUsernameFromLocalStorage,
  68. } from "./data/localStorage";
  69. import CustomStats from "./CustomStats";
  70. import {
  71. restore,
  72. restoreAppState,
  73. RestoredDataState,
  74. } from "../packages/excalidraw/data/restore";
  75. import {
  76. ExportToExcalidrawPlus,
  77. exportToExcalidrawPlus,
  78. } from "./components/ExportToExcalidrawPlus";
  79. import { updateStaleImageStatuses } from "./data/FileManager";
  80. import { newElementWith } from "../packages/excalidraw/element/mutateElement";
  81. import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
  82. import { loadFilesFromFirebase } from "./data/firebase";
  83. import {
  84. LibraryIndexedDBAdapter,
  85. LibraryLocalStorageMigrationAdapter,
  86. LocalData,
  87. } from "./data/LocalData";
  88. import { isBrowserStorageStateNewer } from "./data/tabSync";
  89. import clsx from "clsx";
  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. import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
  107. import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
  108. import {
  109. RemoteExcalidrawElement,
  110. reconcileElements,
  111. } from "../packages/excalidraw/data/reconcile";
  112. import {
  113. CommandPalette,
  114. DEFAULT_CATEGORIES,
  115. } from "../packages/excalidraw/components/CommandPalette/CommandPalette";
  116. import {
  117. GithubIcon,
  118. XBrandIcon,
  119. DiscordIcon,
  120. ExcalLogo,
  121. usersIcon,
  122. exportToPlus,
  123. share,
  124. } from "../packages/excalidraw/components/icons";
  125. import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
  126. polyfill();
  127. window.EXCALIDRAW_THROTTLE_RENDER = true;
  128. let isSelfEmbedding = false;
  129. if (window.self !== window.top) {
  130. try {
  131. const parentUrl = new URL(document.referrer);
  132. const currentUrl = new URL(window.location.href);
  133. if (parentUrl.origin === currentUrl.origin) {
  134. isSelfEmbedding = true;
  135. }
  136. } catch (error) {
  137. // ignore
  138. }
  139. }
  140. const languageDetector = new LanguageDetector();
  141. languageDetector.init({
  142. languageUtils: {},
  143. });
  144. const shareableLinkConfirmDialog = {
  145. title: t("overwriteConfirm.modal.shareableLink.title"),
  146. description: (
  147. <Trans
  148. i18nKey="overwriteConfirm.modal.shareableLink.description"
  149. bold={(text) => <strong>{text}</strong>}
  150. br={() => <br />}
  151. />
  152. ),
  153. actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
  154. color: "danger",
  155. } as const;
  156. const initializeScene = async (opts: {
  157. collabAPI: CollabAPI | null;
  158. excalidrawAPI: ExcalidrawImperativeAPI;
  159. }): Promise<
  160. { scene: ExcalidrawInitialDataState | null } & (
  161. | { isExternalScene: true; id: string; key: string }
  162. | { isExternalScene: false; id?: null; key?: null }
  163. )
  164. > => {
  165. const searchParams = new URLSearchParams(window.location.search);
  166. const id = searchParams.get("id");
  167. const jsonBackendMatch = window.location.hash.match(
  168. /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
  169. );
  170. const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
  171. const localDataState = importFromLocalStorage();
  172. let scene: RestoredDataState & {
  173. scrollToContent?: boolean;
  174. } = await loadScene(null, null, localDataState);
  175. let roomLinkData = getCollaborationLinkData(window.location.href);
  176. const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
  177. if (isExternalScene) {
  178. if (
  179. // don't prompt if scene is empty
  180. !scene.elements.length ||
  181. // don't prompt for collab scenes because we don't override local storage
  182. roomLinkData ||
  183. // otherwise, prompt whether user wants to override current scene
  184. (await openConfirmModal(shareableLinkConfirmDialog))
  185. ) {
  186. if (jsonBackendMatch) {
  187. scene = await loadScene(
  188. jsonBackendMatch[1],
  189. jsonBackendMatch[2],
  190. localDataState,
  191. );
  192. }
  193. scene.scrollToContent = true;
  194. if (!roomLinkData) {
  195. window.history.replaceState({}, APP_NAME, window.location.origin);
  196. }
  197. } else {
  198. // https://github.com/excalidraw/excalidraw/issues/1919
  199. if (document.hidden) {
  200. return new Promise((resolve, reject) => {
  201. window.addEventListener(
  202. "focus",
  203. () => initializeScene(opts).then(resolve).catch(reject),
  204. {
  205. once: true,
  206. },
  207. );
  208. });
  209. }
  210. roomLinkData = null;
  211. window.history.replaceState({}, APP_NAME, window.location.origin);
  212. }
  213. } else if (externalUrlMatch) {
  214. window.history.replaceState({}, APP_NAME, window.location.origin);
  215. const url = externalUrlMatch[1];
  216. try {
  217. const request = await fetch(window.decodeURIComponent(url));
  218. const data = await loadFromBlob(await request.blob(), null, null);
  219. if (
  220. !scene.elements.length ||
  221. (await openConfirmModal(shareableLinkConfirmDialog))
  222. ) {
  223. return { scene: data, isExternalScene };
  224. }
  225. } catch (error: any) {
  226. return {
  227. scene: {
  228. appState: {
  229. errorMessage: t("alerts.invalidSceneUrl"),
  230. },
  231. },
  232. isExternalScene,
  233. };
  234. }
  235. }
  236. if (roomLinkData && opts.collabAPI) {
  237. const { excalidrawAPI } = opts;
  238. const scene = await opts.collabAPI.startCollaboration(roomLinkData);
  239. return {
  240. // when collaborating, the state may have already been updated at this
  241. // point (we may have received updates from other clients), so reconcile
  242. // elements and appState with existing state
  243. scene: {
  244. ...scene,
  245. appState: {
  246. ...restoreAppState(
  247. {
  248. ...scene?.appState,
  249. theme: localDataState?.appState?.theme || scene?.appState?.theme,
  250. },
  251. excalidrawAPI.getAppState(),
  252. ),
  253. // necessary if we're invoking from a hashchange handler which doesn't
  254. // go through App.initializeScene() that resets this flag
  255. isLoading: false,
  256. },
  257. elements: reconcileElements(
  258. scene?.elements || [],
  259. excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
  260. excalidrawAPI.getAppState(),
  261. ),
  262. },
  263. isExternalScene: true,
  264. id: roomLinkData.roomId,
  265. key: roomLinkData.roomKey,
  266. };
  267. } else if (scene) {
  268. return isExternalScene && jsonBackendMatch
  269. ? {
  270. scene,
  271. isExternalScene,
  272. id: jsonBackendMatch[1],
  273. key: jsonBackendMatch[2],
  274. }
  275. : { scene, isExternalScene: false };
  276. }
  277. return { scene: null, isExternalScene: false };
  278. };
  279. const detectedLangCode = languageDetector.detect() || defaultLang.code;
  280. export const appLangCodeAtom = atom(
  281. Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
  282. );
  283. const ExcalidrawWrapper = () => {
  284. const [errorMessage, setErrorMessage] = useState("");
  285. const [langCode, setLangCode] = useAtom(appLangCodeAtom);
  286. const isCollabDisabled = isRunningInIframe();
  287. const [appTheme, setAppTheme] = useAtom(appThemeAtom);
  288. const { editorTheme } = useHandleAppTheme();
  289. // initial state
  290. // ---------------------------------------------------------------------------
  291. const initialStatePromiseRef = useRef<{
  292. promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
  293. }>({ promise: null! });
  294. if (!initialStatePromiseRef.current.promise) {
  295. initialStatePromiseRef.current.promise =
  296. resolvablePromise<ExcalidrawInitialDataState | null>();
  297. }
  298. useEffect(() => {
  299. trackEvent("load", "frame", getFrame());
  300. // Delayed so that the app has a time to load the latest SW
  301. setTimeout(() => {
  302. trackEvent("load", "version", getVersion());
  303. }, VERSION_TIMEOUT);
  304. }, []);
  305. const [excalidrawAPI, excalidrawRefCallback] =
  306. useCallbackRefState<ExcalidrawImperativeAPI>();
  307. const [, setShareDialogState] = useAtom(shareDialogStateAtom);
  308. const [collabAPI] = useAtom(collabAPIAtom);
  309. const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
  310. return isCollaborationLink(window.location.href);
  311. });
  312. const collabError = useAtomValue(collabErrorIndicatorAtom);
  313. useHandleLibrary({
  314. excalidrawAPI,
  315. adapter: LibraryIndexedDBAdapter,
  316. // TODO maybe remove this in several months (shipped: 24-03-11)
  317. migrationAdapter: LibraryLocalStorageMigrationAdapter,
  318. });
  319. useEffect(() => {
  320. if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
  321. return;
  322. }
  323. const loadImages = (
  324. data: ResolutionType<typeof initializeScene>,
  325. isInitialLoad = false,
  326. ) => {
  327. if (!data.scene) {
  328. return;
  329. }
  330. if (collabAPI?.isCollaborating()) {
  331. if (data.scene.elements) {
  332. collabAPI
  333. .fetchImageFilesFromFirebase({
  334. elements: data.scene.elements,
  335. forceFetchFiles: true,
  336. })
  337. .then(({ loadedFiles, erroredFiles }) => {
  338. excalidrawAPI.addFiles(loadedFiles);
  339. updateStaleImageStatuses({
  340. excalidrawAPI,
  341. erroredFiles,
  342. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  343. });
  344. });
  345. }
  346. } else {
  347. const fileIds =
  348. data.scene.elements?.reduce((acc, element) => {
  349. if (isInitializedImageElement(element)) {
  350. return acc.concat(element.fileId);
  351. }
  352. return acc;
  353. }, [] as FileId[]) || [];
  354. if (data.isExternalScene) {
  355. loadFilesFromFirebase(
  356. `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
  357. data.key,
  358. fileIds,
  359. ).then(({ loadedFiles, erroredFiles }) => {
  360. excalidrawAPI.addFiles(loadedFiles);
  361. updateStaleImageStatuses({
  362. excalidrawAPI,
  363. erroredFiles,
  364. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  365. });
  366. });
  367. } else if (isInitialLoad) {
  368. if (fileIds.length) {
  369. LocalData.fileStorage
  370. .getFiles(fileIds)
  371. .then(({ loadedFiles, erroredFiles }) => {
  372. if (loadedFiles.length) {
  373. excalidrawAPI.addFiles(loadedFiles);
  374. }
  375. updateStaleImageStatuses({
  376. excalidrawAPI,
  377. erroredFiles,
  378. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  379. });
  380. });
  381. }
  382. // on fresh load, clear unused files from IDB (from previous
  383. // session)
  384. LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
  385. }
  386. }
  387. };
  388. initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
  389. loadImages(data, /* isInitialLoad */ true);
  390. initialStatePromiseRef.current.promise.resolve(data.scene);
  391. });
  392. const onHashChange = async (event: HashChangeEvent) => {
  393. event.preventDefault();
  394. const libraryUrlTokens = parseLibraryTokensFromUrl();
  395. if (!libraryUrlTokens) {
  396. if (
  397. collabAPI?.isCollaborating() &&
  398. !isCollaborationLink(window.location.href)
  399. ) {
  400. collabAPI.stopCollaboration(false);
  401. }
  402. excalidrawAPI.updateScene({ appState: { isLoading: true } });
  403. initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
  404. loadImages(data);
  405. if (data.scene) {
  406. excalidrawAPI.updateScene({
  407. ...data.scene,
  408. ...restore(data.scene, null, null, { repairBindings: true }),
  409. commitToHistory: true,
  410. });
  411. }
  412. });
  413. }
  414. };
  415. const titleTimeout = setTimeout(
  416. () => (document.title = APP_NAME),
  417. TITLE_TIMEOUT,
  418. );
  419. const syncData = debounce(() => {
  420. if (isTestEnv()) {
  421. return;
  422. }
  423. if (
  424. !document.hidden &&
  425. ((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
  426. ) {
  427. // don't sync if local state is newer or identical to browser state
  428. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
  429. const localDataState = importFromLocalStorage();
  430. const username = importUsernameFromLocalStorage();
  431. let langCode = languageDetector.detect() || defaultLang.code;
  432. if (Array.isArray(langCode)) {
  433. langCode = langCode[0];
  434. }
  435. setLangCode(langCode);
  436. excalidrawAPI.updateScene({
  437. ...localDataState,
  438. });
  439. LibraryIndexedDBAdapter.load().then((data) => {
  440. if (data) {
  441. excalidrawAPI.updateLibrary({
  442. libraryItems: data.libraryItems,
  443. });
  444. }
  445. });
  446. collabAPI?.setUsername(username || "");
  447. }
  448. if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
  449. const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
  450. const currFiles = excalidrawAPI.getFiles();
  451. const fileIds =
  452. elements?.reduce((acc, element) => {
  453. if (
  454. isInitializedImageElement(element) &&
  455. // only load and update images that aren't already loaded
  456. !currFiles[element.fileId]
  457. ) {
  458. return acc.concat(element.fileId);
  459. }
  460. return acc;
  461. }, [] as FileId[]) || [];
  462. if (fileIds.length) {
  463. LocalData.fileStorage
  464. .getFiles(fileIds)
  465. .then(({ loadedFiles, erroredFiles }) => {
  466. if (loadedFiles.length) {
  467. excalidrawAPI.addFiles(loadedFiles);
  468. }
  469. updateStaleImageStatuses({
  470. excalidrawAPI,
  471. erroredFiles,
  472. elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
  473. });
  474. });
  475. }
  476. }
  477. }
  478. }, SYNC_BROWSER_TABS_TIMEOUT);
  479. const onUnload = () => {
  480. LocalData.flushSave();
  481. };
  482. const visibilityChange = (event: FocusEvent | Event) => {
  483. if (event.type === EVENT.BLUR || document.hidden) {
  484. LocalData.flushSave();
  485. }
  486. if (
  487. event.type === EVENT.VISIBILITY_CHANGE ||
  488. event.type === EVENT.FOCUS
  489. ) {
  490. syncData();
  491. }
  492. };
  493. window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
  494. window.addEventListener(EVENT.UNLOAD, onUnload, false);
  495. window.addEventListener(EVENT.BLUR, visibilityChange, false);
  496. document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
  497. window.addEventListener(EVENT.FOCUS, visibilityChange, false);
  498. return () => {
  499. window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
  500. window.removeEventListener(EVENT.UNLOAD, onUnload, false);
  501. window.removeEventListener(EVENT.BLUR, visibilityChange, false);
  502. window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
  503. document.removeEventListener(
  504. EVENT.VISIBILITY_CHANGE,
  505. visibilityChange,
  506. false,
  507. );
  508. clearTimeout(titleTimeout);
  509. };
  510. }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
  511. useEffect(() => {
  512. const unloadHandler = (event: BeforeUnloadEvent) => {
  513. LocalData.flushSave();
  514. if (
  515. excalidrawAPI &&
  516. LocalData.fileStorage.shouldPreventUnload(
  517. excalidrawAPI.getSceneElements(),
  518. )
  519. ) {
  520. preventUnload(event);
  521. }
  522. };
  523. window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  524. return () => {
  525. window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  526. };
  527. }, [excalidrawAPI]);
  528. useEffect(() => {
  529. languageDetector.cacheUserLanguage(langCode);
  530. }, [langCode]);
  531. const onChange = (
  532. elements: readonly OrderedExcalidrawElement[],
  533. appState: AppState,
  534. files: BinaryFiles,
  535. ) => {
  536. if (collabAPI?.isCollaborating()) {
  537. collabAPI.syncElements(elements);
  538. }
  539. // this check is redundant, but since this is a hot path, it's best
  540. // not to evaludate the nested expression every time
  541. if (!LocalData.isSavePaused()) {
  542. LocalData.save(elements, appState, files, () => {
  543. if (excalidrawAPI) {
  544. let didChange = false;
  545. const elements = excalidrawAPI
  546. .getSceneElementsIncludingDeleted()
  547. .map((element) => {
  548. if (
  549. LocalData.fileStorage.shouldUpdateImageElementStatus(element)
  550. ) {
  551. const newElement = newElementWith(element, { status: "saved" });
  552. if (newElement !== element) {
  553. didChange = true;
  554. }
  555. return newElement;
  556. }
  557. return element;
  558. });
  559. if (didChange) {
  560. excalidrawAPI.updateScene({
  561. elements,
  562. });
  563. }
  564. }
  565. });
  566. }
  567. };
  568. const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
  569. null,
  570. );
  571. const onExportToBackend = async (
  572. exportedElements: readonly NonDeletedExcalidrawElement[],
  573. appState: Partial<AppState>,
  574. files: BinaryFiles,
  575. ) => {
  576. if (exportedElements.length === 0) {
  577. throw new Error(t("alerts.cannotExportEmptyCanvas"));
  578. }
  579. try {
  580. const { url, errorMessage } = await exportToBackend(
  581. exportedElements,
  582. {
  583. ...appState,
  584. viewBackgroundColor: appState.exportBackground
  585. ? appState.viewBackgroundColor
  586. : getDefaultAppState().viewBackgroundColor,
  587. },
  588. files,
  589. );
  590. if (errorMessage) {
  591. throw new Error(errorMessage);
  592. }
  593. if (url) {
  594. setLatestShareableLink(url);
  595. }
  596. } catch (error: any) {
  597. if (error.name !== "AbortError") {
  598. const { width, height } = appState;
  599. console.error(error, {
  600. width,
  601. height,
  602. devicePixelRatio: window.devicePixelRatio,
  603. });
  604. throw new Error(error.message);
  605. }
  606. }
  607. };
  608. const renderCustomStats = (
  609. elements: readonly NonDeletedExcalidrawElement[],
  610. appState: UIAppState,
  611. ) => {
  612. return (
  613. <CustomStats
  614. setToast={(message) => excalidrawAPI!.setToast({ message })}
  615. appState={appState}
  616. elements={elements}
  617. />
  618. );
  619. };
  620. const isOffline = useAtomValue(isOfflineAtom);
  621. const onCollabDialogOpen = useCallback(
  622. () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
  623. [setShareDialogState],
  624. );
  625. // browsers generally prevent infinite self-embedding, there are
  626. // cases where it still happens, and while we disallow self-embedding
  627. // by not whitelisting our own origin, this serves as an additional guard
  628. if (isSelfEmbedding) {
  629. return (
  630. <div
  631. style={{
  632. display: "flex",
  633. alignItems: "center",
  634. justifyContent: "center",
  635. textAlign: "center",
  636. height: "100%",
  637. }}
  638. >
  639. <h1>I'm not a pretzel!</h1>
  640. </div>
  641. );
  642. }
  643. const ExcalidrawPlusCommand = {
  644. label: "Excalidraw+",
  645. category: DEFAULT_CATEGORIES.links,
  646. predicate: true,
  647. icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
  648. keywords: ["plus", "cloud", "server"],
  649. perform: () => {
  650. window.open(
  651. `${
  652. import.meta.env.VITE_APP_PLUS_LP
  653. }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
  654. "_blank",
  655. );
  656. },
  657. };
  658. const ExcalidrawPlusAppCommand = {
  659. label: "Sign up",
  660. category: DEFAULT_CATEGORIES.links,
  661. predicate: true,
  662. icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
  663. keywords: [
  664. "excalidraw",
  665. "plus",
  666. "cloud",
  667. "server",
  668. "signin",
  669. "login",
  670. "signup",
  671. ],
  672. perform: () => {
  673. window.open(
  674. `${
  675. import.meta.env.VITE_APP_PLUS_APP
  676. }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
  677. "_blank",
  678. );
  679. },
  680. };
  681. return (
  682. <div
  683. style={{ height: "100%" }}
  684. className={clsx("excalidraw-app", {
  685. "is-collaborating": isCollaborating,
  686. })}
  687. >
  688. <Excalidraw
  689. excalidrawAPI={excalidrawRefCallback}
  690. onChange={onChange}
  691. initialData={initialStatePromiseRef.current.promise}
  692. isCollaborating={isCollaborating}
  693. onPointerUpdate={collabAPI?.onPointerUpdate}
  694. UIOptions={{
  695. canvasActions: {
  696. toggleTheme: true,
  697. export: {
  698. onExportToBackend,
  699. renderCustomUI: excalidrawAPI
  700. ? (elements, appState, files) => {
  701. return (
  702. <ExportToExcalidrawPlus
  703. elements={elements}
  704. appState={appState}
  705. files={files}
  706. name={excalidrawAPI.getName()}
  707. onError={(error) => {
  708. excalidrawAPI?.updateScene({
  709. appState: {
  710. errorMessage: error.message,
  711. },
  712. });
  713. }}
  714. onSuccess={() => {
  715. excalidrawAPI.updateScene({
  716. appState: { openDialog: null },
  717. });
  718. }}
  719. />
  720. );
  721. }
  722. : undefined,
  723. },
  724. },
  725. }}
  726. langCode={langCode}
  727. renderCustomStats={renderCustomStats}
  728. detectScroll={false}
  729. handleKeyboardGlobally={true}
  730. autoFocus={true}
  731. theme={editorTheme}
  732. renderTopRightUI={(isMobile) => {
  733. if (isMobile || !collabAPI || isCollabDisabled) {
  734. return null;
  735. }
  736. return (
  737. <div className="top-right-ui">
  738. {collabError.message && <CollabError collabError={collabError} />}
  739. <LiveCollaborationTrigger
  740. isCollaborating={isCollaborating}
  741. onSelect={() =>
  742. setShareDialogState({ isOpen: true, type: "share" })
  743. }
  744. />
  745. </div>
  746. );
  747. }}
  748. >
  749. <AppMainMenu
  750. onCollabDialogOpen={onCollabDialogOpen}
  751. isCollaborating={isCollaborating}
  752. isCollabEnabled={!isCollabDisabled}
  753. theme={appTheme}
  754. setTheme={(theme) => setAppTheme(theme)}
  755. />
  756. <AppWelcomeScreen
  757. onCollabDialogOpen={onCollabDialogOpen}
  758. isCollabEnabled={!isCollabDisabled}
  759. />
  760. <OverwriteConfirmDialog>
  761. <OverwriteConfirmDialog.Actions.ExportToImage />
  762. <OverwriteConfirmDialog.Actions.SaveToDisk />
  763. {excalidrawAPI && (
  764. <OverwriteConfirmDialog.Action
  765. title={t("overwriteConfirm.action.excalidrawPlus.title")}
  766. actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
  767. onClick={() => {
  768. exportToExcalidrawPlus(
  769. excalidrawAPI.getSceneElements(),
  770. excalidrawAPI.getAppState(),
  771. excalidrawAPI.getFiles(),
  772. excalidrawAPI.getName(),
  773. );
  774. }}
  775. >
  776. {t("overwriteConfirm.action.excalidrawPlus.description")}
  777. </OverwriteConfirmDialog.Action>
  778. )}
  779. </OverwriteConfirmDialog>
  780. <AppFooter />
  781. <TTDDialog
  782. onTextSubmit={async (input) => {
  783. try {
  784. const response = await fetch(
  785. `${
  786. import.meta.env.VITE_APP_AI_BACKEND
  787. }/v1/ai/text-to-diagram/generate`,
  788. {
  789. method: "POST",
  790. headers: {
  791. Accept: "application/json",
  792. "Content-Type": "application/json",
  793. },
  794. body: JSON.stringify({ prompt: input }),
  795. },
  796. );
  797. const rateLimit = response.headers.has("X-Ratelimit-Limit")
  798. ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
  799. : undefined;
  800. const rateLimitRemaining = response.headers.has(
  801. "X-Ratelimit-Remaining",
  802. )
  803. ? parseInt(
  804. response.headers.get("X-Ratelimit-Remaining") || "0",
  805. 10,
  806. )
  807. : undefined;
  808. const json = await response.json();
  809. if (!response.ok) {
  810. if (response.status === 429) {
  811. return {
  812. rateLimit,
  813. rateLimitRemaining,
  814. error: new Error(
  815. "Too many requests today, please try again tomorrow!",
  816. ),
  817. };
  818. }
  819. throw new Error(json.message || "Generation failed...");
  820. }
  821. const generatedResponse = json.generatedResponse;
  822. if (!generatedResponse) {
  823. throw new Error("Generation failed...");
  824. }
  825. return { generatedResponse, rateLimit, rateLimitRemaining };
  826. } catch (err: any) {
  827. throw new Error("Request failed");
  828. }
  829. }}
  830. />
  831. <TTDDialogTrigger />
  832. {isCollaborating && isOffline && (
  833. <div className="collab-offline-warning">
  834. {t("alerts.collabOfflineWarning")}
  835. </div>
  836. )}
  837. {latestShareableLink && (
  838. <ShareableLinkDialog
  839. link={latestShareableLink}
  840. onCloseRequest={() => setLatestShareableLink(null)}
  841. setErrorMessage={setErrorMessage}
  842. />
  843. )}
  844. {excalidrawAPI && !isCollabDisabled && (
  845. <Collab excalidrawAPI={excalidrawAPI} />
  846. )}
  847. <ShareDialog
  848. collabAPI={collabAPI}
  849. onExportToBackend={async () => {
  850. if (excalidrawAPI) {
  851. try {
  852. await onExportToBackend(
  853. excalidrawAPI.getSceneElements(),
  854. excalidrawAPI.getAppState(),
  855. excalidrawAPI.getFiles(),
  856. );
  857. } catch (error: any) {
  858. setErrorMessage(error.message);
  859. }
  860. }
  861. }}
  862. />
  863. {errorMessage && (
  864. <ErrorDialog onClose={() => setErrorMessage("")}>
  865. {errorMessage}
  866. </ErrorDialog>
  867. )}
  868. <CommandPalette
  869. customCommandPaletteItems={[
  870. {
  871. label: t("labels.liveCollaboration"),
  872. category: DEFAULT_CATEGORIES.app,
  873. keywords: [
  874. "team",
  875. "multiplayer",
  876. "share",
  877. "public",
  878. "session",
  879. "invite",
  880. ],
  881. icon: usersIcon,
  882. perform: () => {
  883. setShareDialogState({
  884. isOpen: true,
  885. type: "collaborationOnly",
  886. });
  887. },
  888. },
  889. {
  890. label: t("roomDialog.button_stopSession"),
  891. category: DEFAULT_CATEGORIES.app,
  892. predicate: () => !!collabAPI?.isCollaborating(),
  893. keywords: [
  894. "stop",
  895. "session",
  896. "end",
  897. "leave",
  898. "close",
  899. "exit",
  900. "collaboration",
  901. ],
  902. perform: () => {
  903. if (collabAPI) {
  904. collabAPI.stopCollaboration();
  905. if (!collabAPI.isCollaborating()) {
  906. setShareDialogState({ isOpen: false });
  907. }
  908. }
  909. },
  910. },
  911. {
  912. label: t("labels.share"),
  913. category: DEFAULT_CATEGORIES.app,
  914. predicate: true,
  915. icon: share,
  916. keywords: [
  917. "link",
  918. "shareable",
  919. "readonly",
  920. "export",
  921. "publish",
  922. "snapshot",
  923. "url",
  924. "collaborate",
  925. "invite",
  926. ],
  927. perform: async () => {
  928. setShareDialogState({ isOpen: true, type: "share" });
  929. },
  930. },
  931. {
  932. label: "GitHub",
  933. icon: GithubIcon,
  934. category: DEFAULT_CATEGORIES.links,
  935. predicate: true,
  936. keywords: [
  937. "issues",
  938. "bugs",
  939. "requests",
  940. "report",
  941. "features",
  942. "social",
  943. "community",
  944. ],
  945. perform: () => {
  946. window.open(
  947. "https://github.com/excalidraw/excalidraw",
  948. "_blank",
  949. "noopener noreferrer",
  950. );
  951. },
  952. },
  953. {
  954. label: t("labels.followUs"),
  955. icon: XBrandIcon,
  956. category: DEFAULT_CATEGORIES.links,
  957. predicate: true,
  958. keywords: ["twitter", "contact", "social", "community"],
  959. perform: () => {
  960. window.open(
  961. "https://x.com/excalidraw",
  962. "_blank",
  963. "noopener noreferrer",
  964. );
  965. },
  966. },
  967. {
  968. label: t("labels.discordChat"),
  969. category: DEFAULT_CATEGORIES.links,
  970. predicate: true,
  971. icon: DiscordIcon,
  972. keywords: [
  973. "chat",
  974. "talk",
  975. "contact",
  976. "bugs",
  977. "requests",
  978. "report",
  979. "feedback",
  980. "suggestions",
  981. "social",
  982. "community",
  983. ],
  984. perform: () => {
  985. window.open(
  986. "https://discord.gg/UexuTaE",
  987. "_blank",
  988. "noopener noreferrer",
  989. );
  990. },
  991. },
  992. ...(isExcalidrawPlusSignedUser
  993. ? [
  994. {
  995. ...ExcalidrawPlusAppCommand,
  996. label: "Sign in / Go to Excalidraw+",
  997. },
  998. ]
  999. : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
  1000. {
  1001. label: t("overwriteConfirm.action.excalidrawPlus.button"),
  1002. category: DEFAULT_CATEGORIES.export,
  1003. icon: exportToPlus,
  1004. predicate: true,
  1005. keywords: ["plus", "export", "save", "backup"],
  1006. perform: () => {
  1007. if (excalidrawAPI) {
  1008. exportToExcalidrawPlus(
  1009. excalidrawAPI.getSceneElements(),
  1010. excalidrawAPI.getAppState(),
  1011. excalidrawAPI.getFiles(),
  1012. excalidrawAPI.getName(),
  1013. );
  1014. }
  1015. },
  1016. },
  1017. {
  1018. ...CommandPalette.defaultItems.toggleTheme,
  1019. perform: () => {
  1020. setAppTheme(
  1021. editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
  1022. );
  1023. },
  1024. },
  1025. ]}
  1026. />
  1027. </Excalidraw>
  1028. </div>
  1029. );
  1030. };
  1031. const ExcalidrawApp = () => {
  1032. return (
  1033. <TopErrorBoundary>
  1034. <Provider unstable_createStore={() => appJotaiStore}>
  1035. <ExcalidrawWrapper />
  1036. </Provider>
  1037. </TopErrorBoundary>
  1038. );
  1039. };
  1040. export default ExcalidrawApp;