App.tsx 35 KB

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