App.tsx 34 KB

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