App.tsx 35 KB

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