App.tsx 35 KB

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