App.tsx 34 KB

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