App.tsx 34 KB

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