App.tsx 37 KB

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