App.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  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. TTDDialogTrigger,
  26. StoreAction,
  27. reconcileElements,
  28. } from "../packages/excalidraw";
  29. import type {
  30. AppState,
  31. ExcalidrawImperativeAPI,
  32. BinaryFiles,
  33. ExcalidrawInitialDataState,
  34. UIAppState,
  35. } from "../packages/excalidraw/types";
  36. import type { ResolvablePromise } from "../packages/excalidraw/utils";
  37. import {
  38. debounce,
  39. getVersion,
  40. getFrame,
  41. isTestEnv,
  42. preventUnload,
  43. resolvablePromise,
  44. isRunningInIframe,
  45. } from "../packages/excalidraw/utils";
  46. import {
  47. FIREBASE_STORAGE_PREFIXES,
  48. isExcalidrawPlusSignedUser,
  49. STORAGE_KEYS,
  50. SYNC_BROWSER_TABS_TIMEOUT,
  51. } from "./app_constants";
  52. import type { CollabAPI } from "./collab/Collab";
  53. import Collab, {
  54. collabAPIAtom,
  55. isCollaboratingAtom,
  56. isOfflineAtom,
  57. } from "./collab/Collab";
  58. import {
  59. exportToBackend,
  60. getCollaborationLinkData,
  61. isCollaborationLink,
  62. loadScene,
  63. } from "./data";
  64. import {
  65. importFromLocalStorage,
  66. importUsernameFromLocalStorage,
  67. } from "./data/localStorage";
  68. import CustomStats from "./CustomStats";
  69. import type { RestoredDataState } from "../packages/excalidraw/data/restore";
  70. import { restore, restoreAppState } from "../packages/excalidraw/data/restore";
  71. import {
  72. ExportToExcalidrawPlus,
  73. exportToExcalidrawPlus,
  74. } from "./components/ExportToExcalidrawPlus";
  75. import { updateStaleImageStatuses } from "./data/FileManager";
  76. import { newElementWith } from "../packages/excalidraw/element/mutateElement";
  77. import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
  78. import { loadFilesFromFirebase } from "./data/firebase";
  79. import {
  80. LibraryIndexedDBAdapter,
  81. LibraryLocalStorageMigrationAdapter,
  82. LocalData,
  83. } from "./data/LocalData";
  84. import { isBrowserStorageStateNewer } from "./data/tabSync";
  85. import clsx from "clsx";
  86. import {
  87. parseLibraryTokensFromUrl,
  88. useHandleLibrary,
  89. } from "../packages/excalidraw/data/library";
  90. import { AppMainMenu } from "./components/AppMainMenu";
  91. import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
  92. import { AppFooter } from "./components/AppFooter";
  93. import { Provider, useAtom, useAtomValue } from "jotai";
  94. import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
  95. import { appJotaiStore } from "./app-jotai";
  96. import "./index.scss";
  97. import type { ResolutionType } from "../packages/excalidraw/utility-types";
  98. import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
  99. import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
  100. import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
  101. import Trans from "../packages/excalidraw/components/Trans";
  102. import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
  103. import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
  104. import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
  105. import {
  106. CommandPalette,
  107. DEFAULT_CATEGORIES,
  108. } from "../packages/excalidraw/components/CommandPalette/CommandPalette";
  109. import {
  110. GithubIcon,
  111. XBrandIcon,
  112. DiscordIcon,
  113. ExcalLogo,
  114. usersIcon,
  115. exportToPlus,
  116. share,
  117. youtubeIcon,
  118. } from "../packages/excalidraw/components/icons";
  119. import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
  120. import { getPreferredLanguage } from "./app-language/language-detector";
  121. import { useAppLangCode } from "./app-language/language-state";
  122. import { AIComponents } from "./components/AI";
  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. {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
  793. <TTDDialogTrigger />
  794. {isCollaborating && isOffline && (
  795. <div className="collab-offline-warning">
  796. {t("alerts.collabOfflineWarning")}
  797. </div>
  798. )}
  799. {latestShareableLink && (
  800. <ShareableLinkDialog
  801. link={latestShareableLink}
  802. onCloseRequest={() => setLatestShareableLink(null)}
  803. setErrorMessage={setErrorMessage}
  804. />
  805. )}
  806. {excalidrawAPI && !isCollabDisabled && (
  807. <Collab excalidrawAPI={excalidrawAPI} />
  808. )}
  809. <ShareDialog
  810. collabAPI={collabAPI}
  811. onExportToBackend={async () => {
  812. if (excalidrawAPI) {
  813. try {
  814. await onExportToBackend(
  815. excalidrawAPI.getSceneElements(),
  816. excalidrawAPI.getAppState(),
  817. excalidrawAPI.getFiles(),
  818. );
  819. } catch (error: any) {
  820. setErrorMessage(error.message);
  821. }
  822. }
  823. }}
  824. />
  825. {errorMessage && (
  826. <ErrorDialog onClose={() => setErrorMessage("")}>
  827. {errorMessage}
  828. </ErrorDialog>
  829. )}
  830. <CommandPalette
  831. customCommandPaletteItems={[
  832. {
  833. label: t("labels.liveCollaboration"),
  834. category: DEFAULT_CATEGORIES.app,
  835. keywords: [
  836. "team",
  837. "multiplayer",
  838. "share",
  839. "public",
  840. "session",
  841. "invite",
  842. ],
  843. icon: usersIcon,
  844. perform: () => {
  845. setShareDialogState({
  846. isOpen: true,
  847. type: "collaborationOnly",
  848. });
  849. },
  850. },
  851. {
  852. label: t("roomDialog.button_stopSession"),
  853. category: DEFAULT_CATEGORIES.app,
  854. predicate: () => !!collabAPI?.isCollaborating(),
  855. keywords: [
  856. "stop",
  857. "session",
  858. "end",
  859. "leave",
  860. "close",
  861. "exit",
  862. "collaboration",
  863. ],
  864. perform: () => {
  865. if (collabAPI) {
  866. collabAPI.stopCollaboration();
  867. if (!collabAPI.isCollaborating()) {
  868. setShareDialogState({ isOpen: false });
  869. }
  870. }
  871. },
  872. },
  873. {
  874. label: t("labels.share"),
  875. category: DEFAULT_CATEGORIES.app,
  876. predicate: true,
  877. icon: share,
  878. keywords: [
  879. "link",
  880. "shareable",
  881. "readonly",
  882. "export",
  883. "publish",
  884. "snapshot",
  885. "url",
  886. "collaborate",
  887. "invite",
  888. ],
  889. perform: async () => {
  890. setShareDialogState({ isOpen: true, type: "share" });
  891. },
  892. },
  893. {
  894. label: "GitHub",
  895. icon: GithubIcon,
  896. category: DEFAULT_CATEGORIES.links,
  897. predicate: true,
  898. keywords: [
  899. "issues",
  900. "bugs",
  901. "requests",
  902. "report",
  903. "features",
  904. "social",
  905. "community",
  906. ],
  907. perform: () => {
  908. window.open(
  909. "https://github.com/excalidraw/excalidraw",
  910. "_blank",
  911. "noopener noreferrer",
  912. );
  913. },
  914. },
  915. {
  916. label: t("labels.followUs"),
  917. icon: XBrandIcon,
  918. category: DEFAULT_CATEGORIES.links,
  919. predicate: true,
  920. keywords: ["twitter", "contact", "social", "community"],
  921. perform: () => {
  922. window.open(
  923. "https://x.com/excalidraw",
  924. "_blank",
  925. "noopener noreferrer",
  926. );
  927. },
  928. },
  929. {
  930. label: t("labels.discordChat"),
  931. category: DEFAULT_CATEGORIES.links,
  932. predicate: true,
  933. icon: DiscordIcon,
  934. keywords: [
  935. "chat",
  936. "talk",
  937. "contact",
  938. "bugs",
  939. "requests",
  940. "report",
  941. "feedback",
  942. "suggestions",
  943. "social",
  944. "community",
  945. ],
  946. perform: () => {
  947. window.open(
  948. "https://discord.gg/UexuTaE",
  949. "_blank",
  950. "noopener noreferrer",
  951. );
  952. },
  953. },
  954. {
  955. label: "YouTube",
  956. icon: youtubeIcon,
  957. category: DEFAULT_CATEGORIES.links,
  958. predicate: true,
  959. keywords: ["features", "tutorials", "howto", "help", "community"],
  960. perform: () => {
  961. window.open(
  962. "https://youtube.com/@excalidraw",
  963. "_blank",
  964. "noopener noreferrer",
  965. );
  966. },
  967. },
  968. ...(isExcalidrawPlusSignedUser
  969. ? [
  970. {
  971. ...ExcalidrawPlusAppCommand,
  972. label: "Sign in / Go to Excalidraw+",
  973. },
  974. ]
  975. : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
  976. {
  977. label: t("overwriteConfirm.action.excalidrawPlus.button"),
  978. category: DEFAULT_CATEGORIES.export,
  979. icon: exportToPlus,
  980. predicate: true,
  981. keywords: ["plus", "export", "save", "backup"],
  982. perform: () => {
  983. if (excalidrawAPI) {
  984. exportToExcalidrawPlus(
  985. excalidrawAPI.getSceneElements(),
  986. excalidrawAPI.getAppState(),
  987. excalidrawAPI.getFiles(),
  988. excalidrawAPI.getName(),
  989. );
  990. }
  991. },
  992. },
  993. {
  994. ...CommandPalette.defaultItems.toggleTheme,
  995. perform: () => {
  996. setAppTheme(
  997. editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
  998. );
  999. },
  1000. },
  1001. {
  1002. label: t("labels.installPWA"),
  1003. category: DEFAULT_CATEGORIES.app,
  1004. predicate: () => !!pwaEvent,
  1005. perform: () => {
  1006. if (pwaEvent) {
  1007. pwaEvent.prompt();
  1008. pwaEvent.userChoice.then(() => {
  1009. // event cannot be reused, but we'll hopefully
  1010. // grab new one as the event should be fired again
  1011. pwaEvent = null;
  1012. });
  1013. }
  1014. },
  1015. },
  1016. ]}
  1017. />
  1018. </Excalidraw>
  1019. </div>
  1020. );
  1021. };
  1022. const ExcalidrawApp = () => {
  1023. return (
  1024. <TopErrorBoundary>
  1025. <Provider unstable_createStore={() => appJotaiStore}>
  1026. <ExcalidrawWrapper />
  1027. </Provider>
  1028. </TopErrorBoundary>
  1029. );
  1030. };
  1031. export default ExcalidrawApp;