App.tsx 35 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166
  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";
  50. import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
  51. import { newElementWith } from "@excalidraw/element";
  52. import { isInitializedImageElement } from "@excalidraw/element";
  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. if (import.meta.env.VITE_APP_DISABLE_PREVENT_UNLOAD !== "true") {
  558. preventUnload(event);
  559. } else {
  560. console.warn(
  561. "preventing unload disabled (VITE_APP_DISABLE_PREVENT_UNLOAD)",
  562. );
  563. }
  564. }
  565. };
  566. window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  567. return () => {
  568. window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
  569. };
  570. }, [excalidrawAPI]);
  571. const onChange = (
  572. elements: readonly OrderedExcalidrawElement[],
  573. appState: AppState,
  574. files: BinaryFiles,
  575. ) => {
  576. if (collabAPI?.isCollaborating()) {
  577. collabAPI.syncElements(elements);
  578. }
  579. // this check is redundant, but since this is a hot path, it's best
  580. // not to evaludate the nested expression every time
  581. if (!LocalData.isSavePaused()) {
  582. LocalData.save(elements, appState, files, () => {
  583. if (excalidrawAPI) {
  584. let didChange = false;
  585. const elements = excalidrawAPI
  586. .getSceneElementsIncludingDeleted()
  587. .map((element) => {
  588. if (
  589. LocalData.fileStorage.shouldUpdateImageElementStatus(element)
  590. ) {
  591. const newElement = newElementWith(element, { status: "saved" });
  592. if (newElement !== element) {
  593. didChange = true;
  594. }
  595. return newElement;
  596. }
  597. return element;
  598. });
  599. if (didChange) {
  600. excalidrawAPI.updateScene({
  601. elements,
  602. captureUpdate: CaptureUpdateAction.NEVER,
  603. });
  604. }
  605. }
  606. });
  607. }
  608. // Render the debug scene if the debug canvas is available
  609. if (debugCanvasRef.current && excalidrawAPI) {
  610. debugRenderer(
  611. debugCanvasRef.current,
  612. appState,
  613. window.devicePixelRatio,
  614. () => forceRefresh((prev) => !prev),
  615. );
  616. }
  617. };
  618. const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
  619. null,
  620. );
  621. const onExportToBackend = async (
  622. exportedElements: readonly NonDeletedExcalidrawElement[],
  623. appState: Partial<AppState>,
  624. files: BinaryFiles,
  625. ) => {
  626. if (exportedElements.length === 0) {
  627. throw new Error(t("alerts.cannotExportEmptyCanvas"));
  628. }
  629. try {
  630. const { url, errorMessage } = await exportToBackend(
  631. exportedElements,
  632. {
  633. ...appState,
  634. viewBackgroundColor: appState.exportBackground
  635. ? appState.viewBackgroundColor
  636. : getDefaultAppState().viewBackgroundColor,
  637. },
  638. files,
  639. );
  640. if (errorMessage) {
  641. throw new Error(errorMessage);
  642. }
  643. if (url) {
  644. setLatestShareableLink(url);
  645. }
  646. } catch (error: any) {
  647. if (error.name !== "AbortError") {
  648. const { width, height } = appState;
  649. console.error(error, {
  650. width,
  651. height,
  652. devicePixelRatio: window.devicePixelRatio,
  653. });
  654. throw new Error(error.message);
  655. }
  656. }
  657. };
  658. const renderCustomStats = (
  659. elements: readonly NonDeletedExcalidrawElement[],
  660. appState: UIAppState,
  661. ) => {
  662. return (
  663. <CustomStats
  664. setToast={(message) => excalidrawAPI!.setToast({ message })}
  665. appState={appState}
  666. elements={elements}
  667. />
  668. );
  669. };
  670. const isOffline = useAtomValue(isOfflineAtom);
  671. const onCollabDialogOpen = useCallback(
  672. () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
  673. [setShareDialogState],
  674. );
  675. // browsers generally prevent infinite self-embedding, there are
  676. // cases where it still happens, and while we disallow self-embedding
  677. // by not whitelisting our own origin, this serves as an additional guard
  678. if (isSelfEmbedding) {
  679. return (
  680. <div
  681. style={{
  682. display: "flex",
  683. alignItems: "center",
  684. justifyContent: "center",
  685. textAlign: "center",
  686. height: "100%",
  687. }}
  688. >
  689. <h1>I'm not a pretzel!</h1>
  690. </div>
  691. );
  692. }
  693. const ExcalidrawPlusCommand = {
  694. label: "Excalidraw+",
  695. category: DEFAULT_CATEGORIES.links,
  696. predicate: true,
  697. icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
  698. keywords: ["plus", "cloud", "server"],
  699. perform: () => {
  700. window.open(
  701. `${
  702. import.meta.env.VITE_APP_PLUS_LP
  703. }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
  704. "_blank",
  705. );
  706. },
  707. };
  708. const ExcalidrawPlusAppCommand = {
  709. label: "Sign up",
  710. category: DEFAULT_CATEGORIES.links,
  711. predicate: true,
  712. icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
  713. keywords: [
  714. "excalidraw",
  715. "plus",
  716. "cloud",
  717. "server",
  718. "signin",
  719. "login",
  720. "signup",
  721. ],
  722. perform: () => {
  723. window.open(
  724. `${
  725. import.meta.env.VITE_APP_PLUS_APP
  726. }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
  727. "_blank",
  728. );
  729. },
  730. };
  731. return (
  732. <div
  733. style={{ height: "100%" }}
  734. className={clsx("excalidraw-app", {
  735. "is-collaborating": isCollaborating,
  736. })}
  737. >
  738. <Excalidraw
  739. excalidrawAPI={excalidrawRefCallback}
  740. onChange={onChange}
  741. initialData={initialStatePromiseRef.current.promise}
  742. isCollaborating={isCollaborating}
  743. onPointerUpdate={collabAPI?.onPointerUpdate}
  744. UIOptions={{
  745. canvasActions: {
  746. toggleTheme: true,
  747. export: {
  748. onExportToBackend,
  749. renderCustomUI: excalidrawAPI
  750. ? (elements, appState, files) => {
  751. return (
  752. <ExportToExcalidrawPlus
  753. elements={elements}
  754. appState={appState}
  755. files={files}
  756. name={excalidrawAPI.getName()}
  757. onError={(error) => {
  758. excalidrawAPI?.updateScene({
  759. appState: {
  760. errorMessage: error.message,
  761. },
  762. });
  763. }}
  764. onSuccess={() => {
  765. excalidrawAPI.updateScene({
  766. appState: { openDialog: null },
  767. });
  768. }}
  769. />
  770. );
  771. }
  772. : undefined,
  773. },
  774. },
  775. }}
  776. langCode={langCode}
  777. renderCustomStats={renderCustomStats}
  778. detectScroll={false}
  779. handleKeyboardGlobally={true}
  780. autoFocus={true}
  781. theme={editorTheme}
  782. renderTopRightUI={(isMobile) => {
  783. if (isMobile || !collabAPI || isCollabDisabled) {
  784. return null;
  785. }
  786. return (
  787. <div className="top-right-ui">
  788. {collabError.message && <CollabError collabError={collabError} />}
  789. <LiveCollaborationTrigger
  790. isCollaborating={isCollaborating}
  791. onSelect={() =>
  792. setShareDialogState({ isOpen: true, type: "share" })
  793. }
  794. />
  795. </div>
  796. );
  797. }}
  798. onLinkOpen={(element, event) => {
  799. if (element.link && isElementLink(element.link)) {
  800. event.preventDefault();
  801. excalidrawAPI?.scrollToContent(element.link, { animate: true });
  802. }
  803. }}
  804. >
  805. <AppMainMenu
  806. onCollabDialogOpen={onCollabDialogOpen}
  807. isCollaborating={isCollaborating}
  808. isCollabEnabled={!isCollabDisabled}
  809. theme={appTheme}
  810. setTheme={(theme) => setAppTheme(theme)}
  811. refresh={() => forceRefresh((prev) => !prev)}
  812. />
  813. <AppWelcomeScreen
  814. onCollabDialogOpen={onCollabDialogOpen}
  815. isCollabEnabled={!isCollabDisabled}
  816. />
  817. <OverwriteConfirmDialog>
  818. <OverwriteConfirmDialog.Actions.ExportToImage />
  819. <OverwriteConfirmDialog.Actions.SaveToDisk />
  820. {excalidrawAPI && (
  821. <OverwriteConfirmDialog.Action
  822. title={t("overwriteConfirm.action.excalidrawPlus.title")}
  823. actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
  824. onClick={() => {
  825. exportToExcalidrawPlus(
  826. excalidrawAPI.getSceneElements(),
  827. excalidrawAPI.getAppState(),
  828. excalidrawAPI.getFiles(),
  829. excalidrawAPI.getName(),
  830. );
  831. }}
  832. >
  833. {t("overwriteConfirm.action.excalidrawPlus.description")}
  834. </OverwriteConfirmDialog.Action>
  835. )}
  836. </OverwriteConfirmDialog>
  837. <AppFooter onChange={() => excalidrawAPI?.refresh()} />
  838. {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />}
  839. <TTDDialogTrigger />
  840. {isCollaborating && isOffline && (
  841. <div className="collab-offline-warning">
  842. {t("alerts.collabOfflineWarning")}
  843. </div>
  844. )}
  845. {latestShareableLink && (
  846. <ShareableLinkDialog
  847. link={latestShareableLink}
  848. onCloseRequest={() => setLatestShareableLink(null)}
  849. setErrorMessage={setErrorMessage}
  850. />
  851. )}
  852. {excalidrawAPI && !isCollabDisabled && (
  853. <Collab excalidrawAPI={excalidrawAPI} />
  854. )}
  855. <ShareDialog
  856. collabAPI={collabAPI}
  857. onExportToBackend={async () => {
  858. if (excalidrawAPI) {
  859. try {
  860. await onExportToBackend(
  861. excalidrawAPI.getSceneElements(),
  862. excalidrawAPI.getAppState(),
  863. excalidrawAPI.getFiles(),
  864. );
  865. } catch (error: any) {
  866. setErrorMessage(error.message);
  867. }
  868. }
  869. }}
  870. />
  871. {errorMessage && (
  872. <ErrorDialog onClose={() => setErrorMessage("")}>
  873. {errorMessage}
  874. </ErrorDialog>
  875. )}
  876. <CommandPalette
  877. customCommandPaletteItems={[
  878. {
  879. label: t("labels.liveCollaboration"),
  880. category: DEFAULT_CATEGORIES.app,
  881. keywords: [
  882. "team",
  883. "multiplayer",
  884. "share",
  885. "public",
  886. "session",
  887. "invite",
  888. ],
  889. icon: usersIcon,
  890. perform: () => {
  891. setShareDialogState({
  892. isOpen: true,
  893. type: "collaborationOnly",
  894. });
  895. },
  896. },
  897. {
  898. label: t("roomDialog.button_stopSession"),
  899. category: DEFAULT_CATEGORIES.app,
  900. predicate: () => !!collabAPI?.isCollaborating(),
  901. keywords: [
  902. "stop",
  903. "session",
  904. "end",
  905. "leave",
  906. "close",
  907. "exit",
  908. "collaboration",
  909. ],
  910. perform: () => {
  911. if (collabAPI) {
  912. collabAPI.stopCollaboration();
  913. if (!collabAPI.isCollaborating()) {
  914. setShareDialogState({ isOpen: false });
  915. }
  916. }
  917. },
  918. },
  919. {
  920. label: t("labels.share"),
  921. category: DEFAULT_CATEGORIES.app,
  922. predicate: true,
  923. icon: share,
  924. keywords: [
  925. "link",
  926. "shareable",
  927. "readonly",
  928. "export",
  929. "publish",
  930. "snapshot",
  931. "url",
  932. "collaborate",
  933. "invite",
  934. ],
  935. perform: async () => {
  936. setShareDialogState({ isOpen: true, type: "share" });
  937. },
  938. },
  939. {
  940. label: "GitHub",
  941. icon: GithubIcon,
  942. category: DEFAULT_CATEGORIES.links,
  943. predicate: true,
  944. keywords: [
  945. "issues",
  946. "bugs",
  947. "requests",
  948. "report",
  949. "features",
  950. "social",
  951. "community",
  952. ],
  953. perform: () => {
  954. window.open(
  955. "https://github.com/excalidraw/excalidraw",
  956. "_blank",
  957. "noopener noreferrer",
  958. );
  959. },
  960. },
  961. {
  962. label: t("labels.followUs"),
  963. icon: XBrandIcon,
  964. category: DEFAULT_CATEGORIES.links,
  965. predicate: true,
  966. keywords: ["twitter", "contact", "social", "community"],
  967. perform: () => {
  968. window.open(
  969. "https://x.com/excalidraw",
  970. "_blank",
  971. "noopener noreferrer",
  972. );
  973. },
  974. },
  975. {
  976. label: t("labels.discordChat"),
  977. category: DEFAULT_CATEGORIES.links,
  978. predicate: true,
  979. icon: DiscordIcon,
  980. keywords: [
  981. "chat",
  982. "talk",
  983. "contact",
  984. "bugs",
  985. "requests",
  986. "report",
  987. "feedback",
  988. "suggestions",
  989. "social",
  990. "community",
  991. ],
  992. perform: () => {
  993. window.open(
  994. "https://discord.gg/UexuTaE",
  995. "_blank",
  996. "noopener noreferrer",
  997. );
  998. },
  999. },
  1000. {
  1001. label: "YouTube",
  1002. icon: youtubeIcon,
  1003. category: DEFAULT_CATEGORIES.links,
  1004. predicate: true,
  1005. keywords: ["features", "tutorials", "howto", "help", "community"],
  1006. perform: () => {
  1007. window.open(
  1008. "https://youtube.com/@excalidraw",
  1009. "_blank",
  1010. "noopener noreferrer",
  1011. );
  1012. },
  1013. },
  1014. ...(isExcalidrawPlusSignedUser
  1015. ? [
  1016. {
  1017. ...ExcalidrawPlusAppCommand,
  1018. label: "Sign in / Go to Excalidraw+",
  1019. },
  1020. ]
  1021. : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
  1022. {
  1023. label: t("overwriteConfirm.action.excalidrawPlus.button"),
  1024. category: DEFAULT_CATEGORIES.export,
  1025. icon: exportToPlus,
  1026. predicate: true,
  1027. keywords: ["plus", "export", "save", "backup"],
  1028. perform: () => {
  1029. if (excalidrawAPI) {
  1030. exportToExcalidrawPlus(
  1031. excalidrawAPI.getSceneElements(),
  1032. excalidrawAPI.getAppState(),
  1033. excalidrawAPI.getFiles(),
  1034. excalidrawAPI.getName(),
  1035. );
  1036. }
  1037. },
  1038. },
  1039. {
  1040. ...CommandPalette.defaultItems.toggleTheme,
  1041. perform: () => {
  1042. setAppTheme(
  1043. editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
  1044. );
  1045. },
  1046. },
  1047. {
  1048. label: t("labels.installPWA"),
  1049. category: DEFAULT_CATEGORIES.app,
  1050. predicate: () => !!pwaEvent,
  1051. perform: () => {
  1052. if (pwaEvent) {
  1053. pwaEvent.prompt();
  1054. pwaEvent.userChoice.then(() => {
  1055. // event cannot be reused, but we'll hopefully
  1056. // grab new one as the event should be fired again
  1057. pwaEvent = null;
  1058. });
  1059. }
  1060. },
  1061. },
  1062. ]}
  1063. />
  1064. {isVisualDebuggerEnabled() && excalidrawAPI && (
  1065. <DebugCanvas
  1066. appState={excalidrawAPI.getAppState()}
  1067. scale={window.devicePixelRatio}
  1068. ref={debugCanvasRef}
  1069. />
  1070. )}
  1071. </Excalidraw>
  1072. </div>
  1073. );
  1074. };
  1075. const ExcalidrawApp = () => {
  1076. const isCloudExportWindow =
  1077. window.location.pathname === "/excalidraw-plus-export";
  1078. if (isCloudExportWindow) {
  1079. return <ExcalidrawPlusIframeExport />;
  1080. }
  1081. return (
  1082. <TopErrorBoundary>
  1083. <Provider store={appJotaiStore}>
  1084. <ExcalidrawWrapper />
  1085. </Provider>
  1086. </TopErrorBoundary>
  1087. );
  1088. };
  1089. export default ExcalidrawApp;