mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-10-25 00:44:38 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			1123 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			1123 lines
		
	
	
		
			34 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import polyfill from "../packages/excalidraw/polyfill";
 | |
| import LanguageDetector from "i18next-browser-languagedetector";
 | |
| import { useCallback, useEffect, useRef, useState } from "react";
 | |
| import { trackEvent } from "../packages/excalidraw/analytics";
 | |
| import { getDefaultAppState } from "../packages/excalidraw/appState";
 | |
| import { ErrorDialog } from "../packages/excalidraw/components/ErrorDialog";
 | |
| import { TopErrorBoundary } from "./components/TopErrorBoundary";
 | |
| import {
 | |
|   APP_NAME,
 | |
|   EVENT,
 | |
|   THEME,
 | |
|   TITLE_TIMEOUT,
 | |
|   VERSION_TIMEOUT,
 | |
| } from "../packages/excalidraw/constants";
 | |
| import { loadFromBlob } from "../packages/excalidraw/data/blob";
 | |
| import {
 | |
|   FileId,
 | |
|   NonDeletedExcalidrawElement,
 | |
|   OrderedExcalidrawElement,
 | |
| } from "../packages/excalidraw/element/types";
 | |
| import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
 | |
| import { t } from "../packages/excalidraw/i18n";
 | |
| import {
 | |
|   Excalidraw,
 | |
|   defaultLang,
 | |
|   LiveCollaborationTrigger,
 | |
|   TTDDialog,
 | |
|   TTDDialogTrigger,
 | |
| } from "../packages/excalidraw/index";
 | |
| import {
 | |
|   AppState,
 | |
|   ExcalidrawImperativeAPI,
 | |
|   BinaryFiles,
 | |
|   ExcalidrawInitialDataState,
 | |
|   UIAppState,
 | |
| } from "../packages/excalidraw/types";
 | |
| import {
 | |
|   debounce,
 | |
|   getVersion,
 | |
|   getFrame,
 | |
|   isTestEnv,
 | |
|   preventUnload,
 | |
|   ResolvablePromise,
 | |
|   resolvablePromise,
 | |
|   isRunningInIframe,
 | |
| } from "../packages/excalidraw/utils";
 | |
| import {
 | |
|   FIREBASE_STORAGE_PREFIXES,
 | |
|   isExcalidrawPlusSignedUser,
 | |
|   STORAGE_KEYS,
 | |
|   SYNC_BROWSER_TABS_TIMEOUT,
 | |
| } from "./app_constants";
 | |
| import Collab, {
 | |
|   CollabAPI,
 | |
|   collabAPIAtom,
 | |
|   isCollaboratingAtom,
 | |
|   isOfflineAtom,
 | |
| } from "./collab/Collab";
 | |
| import {
 | |
|   exportToBackend,
 | |
|   getCollaborationLinkData,
 | |
|   isCollaborationLink,
 | |
|   loadScene,
 | |
| } from "./data";
 | |
| import {
 | |
|   importFromLocalStorage,
 | |
|   importUsernameFromLocalStorage,
 | |
| } from "./data/localStorage";
 | |
| import CustomStats from "./CustomStats";
 | |
| import {
 | |
|   restore,
 | |
|   restoreAppState,
 | |
|   RestoredDataState,
 | |
| } from "../packages/excalidraw/data/restore";
 | |
| import {
 | |
|   ExportToExcalidrawPlus,
 | |
|   exportToExcalidrawPlus,
 | |
| } from "./components/ExportToExcalidrawPlus";
 | |
| import { updateStaleImageStatuses } from "./data/FileManager";
 | |
| import { newElementWith } from "../packages/excalidraw/element/mutateElement";
 | |
| import { isInitializedImageElement } from "../packages/excalidraw/element/typeChecks";
 | |
| import { loadFilesFromFirebase } from "./data/firebase";
 | |
| import {
 | |
|   LibraryIndexedDBAdapter,
 | |
|   LibraryLocalStorageMigrationAdapter,
 | |
|   LocalData,
 | |
| } from "./data/LocalData";
 | |
| import { isBrowserStorageStateNewer } from "./data/tabSync";
 | |
| import clsx from "clsx";
 | |
| import {
 | |
|   parseLibraryTokensFromUrl,
 | |
|   useHandleLibrary,
 | |
| } from "../packages/excalidraw/data/library";
 | |
| import { AppMainMenu } from "./components/AppMainMenu";
 | |
| import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
 | |
| import { AppFooter } from "./components/AppFooter";
 | |
| import { atom, Provider, useAtom, useAtomValue } from "jotai";
 | |
| import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
 | |
| import { appJotaiStore } from "./app-jotai";
 | |
| 
 | |
| import "./index.scss";
 | |
| import { ResolutionType } from "../packages/excalidraw/utility-types";
 | |
| import { ShareableLinkDialog } from "../packages/excalidraw/components/ShareableLinkDialog";
 | |
| import { openConfirmModal } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
 | |
| import { OverwriteConfirmDialog } from "../packages/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
 | |
| import Trans from "../packages/excalidraw/components/Trans";
 | |
| import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
 | |
| import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
 | |
| import {
 | |
|   RemoteExcalidrawElement,
 | |
|   reconcileElements,
 | |
| } from "../packages/excalidraw/data/reconcile";
 | |
| import {
 | |
|   CommandPalette,
 | |
|   DEFAULT_CATEGORIES,
 | |
| } from "../packages/excalidraw/components/CommandPalette/CommandPalette";
 | |
| import {
 | |
|   GithubIcon,
 | |
|   XBrandIcon,
 | |
|   DiscordIcon,
 | |
|   ExcalLogo,
 | |
|   usersIcon,
 | |
|   exportToPlus,
 | |
|   share,
 | |
|   youtubeIcon,
 | |
| } from "../packages/excalidraw/components/icons";
 | |
| import { appThemeAtom, useHandleAppTheme } from "./useHandleAppTheme";
 | |
| 
 | |
| polyfill();
 | |
| 
 | |
| window.EXCALIDRAW_THROTTLE_RENDER = true;
 | |
| 
 | |
| let isSelfEmbedding = false;
 | |
| 
 | |
| if (window.self !== window.top) {
 | |
|   try {
 | |
|     const parentUrl = new URL(document.referrer);
 | |
|     const currentUrl = new URL(window.location.href);
 | |
|     if (parentUrl.origin === currentUrl.origin) {
 | |
|       isSelfEmbedding = true;
 | |
|     }
 | |
|   } catch (error) {
 | |
|     // ignore
 | |
|   }
 | |
| }
 | |
| 
 | |
| const languageDetector = new LanguageDetector();
 | |
| languageDetector.init({
 | |
|   languageUtils: {},
 | |
| });
 | |
| 
 | |
| const shareableLinkConfirmDialog = {
 | |
|   title: t("overwriteConfirm.modal.shareableLink.title"),
 | |
|   description: (
 | |
|     <Trans
 | |
|       i18nKey="overwriteConfirm.modal.shareableLink.description"
 | |
|       bold={(text) => <strong>{text}</strong>}
 | |
|       br={() => <br />}
 | |
|     />
 | |
|   ),
 | |
|   actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
 | |
|   color: "danger",
 | |
| } as const;
 | |
| 
 | |
| const initializeScene = async (opts: {
 | |
|   collabAPI: CollabAPI | null;
 | |
|   excalidrawAPI: ExcalidrawImperativeAPI;
 | |
| }): Promise<
 | |
|   { scene: ExcalidrawInitialDataState | null } & (
 | |
|     | { isExternalScene: true; id: string; key: string }
 | |
|     | { isExternalScene: false; id?: null; key?: null }
 | |
|   )
 | |
| > => {
 | |
|   const searchParams = new URLSearchParams(window.location.search);
 | |
|   const id = searchParams.get("id");
 | |
|   const jsonBackendMatch = window.location.hash.match(
 | |
|     /^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
 | |
|   );
 | |
|   const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
 | |
| 
 | |
|   const localDataState = importFromLocalStorage();
 | |
| 
 | |
|   let scene: RestoredDataState & {
 | |
|     scrollToContent?: boolean;
 | |
|   } = await loadScene(null, null, localDataState);
 | |
| 
 | |
|   let roomLinkData = getCollaborationLinkData(window.location.href);
 | |
|   const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
 | |
|   if (isExternalScene) {
 | |
|     if (
 | |
|       // don't prompt if scene is empty
 | |
|       !scene.elements.length ||
 | |
|       // don't prompt for collab scenes because we don't override local storage
 | |
|       roomLinkData ||
 | |
|       // otherwise, prompt whether user wants to override current scene
 | |
|       (await openConfirmModal(shareableLinkConfirmDialog))
 | |
|     ) {
 | |
|       if (jsonBackendMatch) {
 | |
|         scene = await loadScene(
 | |
|           jsonBackendMatch[1],
 | |
|           jsonBackendMatch[2],
 | |
|           localDataState,
 | |
|         );
 | |
|       }
 | |
|       scene.scrollToContent = true;
 | |
|       if (!roomLinkData) {
 | |
|         window.history.replaceState({}, APP_NAME, window.location.origin);
 | |
|       }
 | |
|     } else {
 | |
|       // https://github.com/excalidraw/excalidraw/issues/1919
 | |
|       if (document.hidden) {
 | |
|         return new Promise((resolve, reject) => {
 | |
|           window.addEventListener(
 | |
|             "focus",
 | |
|             () => initializeScene(opts).then(resolve).catch(reject),
 | |
|             {
 | |
|               once: true,
 | |
|             },
 | |
|           );
 | |
|         });
 | |
|       }
 | |
| 
 | |
|       roomLinkData = null;
 | |
|       window.history.replaceState({}, APP_NAME, window.location.origin);
 | |
|     }
 | |
|   } else if (externalUrlMatch) {
 | |
|     window.history.replaceState({}, APP_NAME, window.location.origin);
 | |
| 
 | |
|     const url = externalUrlMatch[1];
 | |
|     try {
 | |
|       const request = await fetch(window.decodeURIComponent(url));
 | |
|       const data = await loadFromBlob(await request.blob(), null, null);
 | |
|       if (
 | |
|         !scene.elements.length ||
 | |
|         (await openConfirmModal(shareableLinkConfirmDialog))
 | |
|       ) {
 | |
|         return { scene: data, isExternalScene };
 | |
|       }
 | |
|     } catch (error: any) {
 | |
|       return {
 | |
|         scene: {
 | |
|           appState: {
 | |
|             errorMessage: t("alerts.invalidSceneUrl"),
 | |
|           },
 | |
|         },
 | |
|         isExternalScene,
 | |
|       };
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   if (roomLinkData && opts.collabAPI) {
 | |
|     const { excalidrawAPI } = opts;
 | |
| 
 | |
|     const scene = await opts.collabAPI.startCollaboration(roomLinkData);
 | |
| 
 | |
|     return {
 | |
|       // when collaborating, the state may have already been updated at this
 | |
|       // point (we may have received updates from other clients), so reconcile
 | |
|       // elements and appState with existing state
 | |
|       scene: {
 | |
|         ...scene,
 | |
|         appState: {
 | |
|           ...restoreAppState(
 | |
|             {
 | |
|               ...scene?.appState,
 | |
|               theme: localDataState?.appState?.theme || scene?.appState?.theme,
 | |
|             },
 | |
|             excalidrawAPI.getAppState(),
 | |
|           ),
 | |
|           // necessary if we're invoking from a hashchange handler which doesn't
 | |
|           // go through App.initializeScene() that resets this flag
 | |
|           isLoading: false,
 | |
|         },
 | |
|         elements: reconcileElements(
 | |
|           scene?.elements || [],
 | |
|           excalidrawAPI.getSceneElementsIncludingDeleted() as RemoteExcalidrawElement[],
 | |
|           excalidrawAPI.getAppState(),
 | |
|         ),
 | |
|       },
 | |
|       isExternalScene: true,
 | |
|       id: roomLinkData.roomId,
 | |
|       key: roomLinkData.roomKey,
 | |
|     };
 | |
|   } else if (scene) {
 | |
|     return isExternalScene && jsonBackendMatch
 | |
|       ? {
 | |
|           scene,
 | |
|           isExternalScene,
 | |
|           id: jsonBackendMatch[1],
 | |
|           key: jsonBackendMatch[2],
 | |
|         }
 | |
|       : { scene, isExternalScene: false };
 | |
|   }
 | |
|   return { scene: null, isExternalScene: false };
 | |
| };
 | |
| 
 | |
| const detectedLangCode = languageDetector.detect() || defaultLang.code;
 | |
| export const appLangCodeAtom = atom(
 | |
|   Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
 | |
| );
 | |
| 
 | |
| const ExcalidrawWrapper = () => {
 | |
|   const [errorMessage, setErrorMessage] = useState("");
 | |
|   const [langCode, setLangCode] = useAtom(appLangCodeAtom);
 | |
|   const isCollabDisabled = isRunningInIframe();
 | |
| 
 | |
|   const [appTheme, setAppTheme] = useAtom(appThemeAtom);
 | |
|   const { editorTheme } = useHandleAppTheme();
 | |
| 
 | |
|   // initial state
 | |
|   // ---------------------------------------------------------------------------
 | |
| 
 | |
|   const initialStatePromiseRef = useRef<{
 | |
|     promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
 | |
|   }>({ promise: null! });
 | |
|   if (!initialStatePromiseRef.current.promise) {
 | |
|     initialStatePromiseRef.current.promise =
 | |
|       resolvablePromise<ExcalidrawInitialDataState | null>();
 | |
|   }
 | |
| 
 | |
|   useEffect(() => {
 | |
|     trackEvent("load", "frame", getFrame());
 | |
|     // Delayed so that the app has a time to load the latest SW
 | |
|     setTimeout(() => {
 | |
|       trackEvent("load", "version", getVersion());
 | |
|     }, VERSION_TIMEOUT);
 | |
|   }, []);
 | |
| 
 | |
|   const [excalidrawAPI, excalidrawRefCallback] =
 | |
|     useCallbackRefState<ExcalidrawImperativeAPI>();
 | |
| 
 | |
|   const [, setShareDialogState] = useAtom(shareDialogStateAtom);
 | |
|   const [collabAPI] = useAtom(collabAPIAtom);
 | |
|   const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
 | |
|     return isCollaborationLink(window.location.href);
 | |
|   });
 | |
|   const collabError = useAtomValue(collabErrorIndicatorAtom);
 | |
| 
 | |
|   useHandleLibrary({
 | |
|     excalidrawAPI,
 | |
|     adapter: LibraryIndexedDBAdapter,
 | |
|     // TODO maybe remove this in several months (shipped: 24-03-11)
 | |
|     migrationAdapter: LibraryLocalStorageMigrationAdapter,
 | |
|   });
 | |
| 
 | |
|   useEffect(() => {
 | |
|     if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
 | |
|       return;
 | |
|     }
 | |
| 
 | |
|     const loadImages = (
 | |
|       data: ResolutionType<typeof initializeScene>,
 | |
|       isInitialLoad = false,
 | |
|     ) => {
 | |
|       if (!data.scene) {
 | |
|         return;
 | |
|       }
 | |
|       if (collabAPI?.isCollaborating()) {
 | |
|         if (data.scene.elements) {
 | |
|           collabAPI
 | |
|             .fetchImageFilesFromFirebase({
 | |
|               elements: data.scene.elements,
 | |
|               forceFetchFiles: true,
 | |
|             })
 | |
|             .then(({ loadedFiles, erroredFiles }) => {
 | |
|               excalidrawAPI.addFiles(loadedFiles);
 | |
|               updateStaleImageStatuses({
 | |
|                 excalidrawAPI,
 | |
|                 erroredFiles,
 | |
|                 elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|               });
 | |
|             });
 | |
|         }
 | |
|       } else {
 | |
|         const fileIds =
 | |
|           data.scene.elements?.reduce((acc, element) => {
 | |
|             if (isInitializedImageElement(element)) {
 | |
|               return acc.concat(element.fileId);
 | |
|             }
 | |
|             return acc;
 | |
|           }, [] as FileId[]) || [];
 | |
| 
 | |
|         if (data.isExternalScene) {
 | |
|           loadFilesFromFirebase(
 | |
|             `${FIREBASE_STORAGE_PREFIXES.shareLinkFiles}/${data.id}`,
 | |
|             data.key,
 | |
|             fileIds,
 | |
|           ).then(({ loadedFiles, erroredFiles }) => {
 | |
|             excalidrawAPI.addFiles(loadedFiles);
 | |
|             updateStaleImageStatuses({
 | |
|               excalidrawAPI,
 | |
|               erroredFiles,
 | |
|               elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|             });
 | |
|           });
 | |
|         } else if (isInitialLoad) {
 | |
|           if (fileIds.length) {
 | |
|             LocalData.fileStorage
 | |
|               .getFiles(fileIds)
 | |
|               .then(({ loadedFiles, erroredFiles }) => {
 | |
|                 if (loadedFiles.length) {
 | |
|                   excalidrawAPI.addFiles(loadedFiles);
 | |
|                 }
 | |
|                 updateStaleImageStatuses({
 | |
|                   excalidrawAPI,
 | |
|                   erroredFiles,
 | |
|                   elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|                 });
 | |
|               });
 | |
|           }
 | |
|           // on fresh load, clear unused files from IDB (from previous
 | |
|           // session)
 | |
|           LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
 | |
|         }
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
 | |
|       loadImages(data, /* isInitialLoad */ true);
 | |
|       initialStatePromiseRef.current.promise.resolve(data.scene);
 | |
|     });
 | |
| 
 | |
|     const onHashChange = async (event: HashChangeEvent) => {
 | |
|       event.preventDefault();
 | |
|       const libraryUrlTokens = parseLibraryTokensFromUrl();
 | |
|       if (!libraryUrlTokens) {
 | |
|         if (
 | |
|           collabAPI?.isCollaborating() &&
 | |
|           !isCollaborationLink(window.location.href)
 | |
|         ) {
 | |
|           collabAPI.stopCollaboration(false);
 | |
|         }
 | |
|         excalidrawAPI.updateScene({ appState: { isLoading: true } });
 | |
| 
 | |
|         initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
 | |
|           loadImages(data);
 | |
|           if (data.scene) {
 | |
|             excalidrawAPI.updateScene({
 | |
|               ...data.scene,
 | |
|               ...restore(data.scene, null, null, { repairBindings: true }),
 | |
|               commitToHistory: true,
 | |
|             });
 | |
|           }
 | |
|         });
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     const titleTimeout = setTimeout(
 | |
|       () => (document.title = APP_NAME),
 | |
|       TITLE_TIMEOUT,
 | |
|     );
 | |
| 
 | |
|     const syncData = debounce(() => {
 | |
|       if (isTestEnv()) {
 | |
|         return;
 | |
|       }
 | |
|       if (
 | |
|         !document.hidden &&
 | |
|         ((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
 | |
|       ) {
 | |
|         // don't sync if local state is newer or identical to browser state
 | |
|         if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
 | |
|           const localDataState = importFromLocalStorage();
 | |
|           const username = importUsernameFromLocalStorage();
 | |
|           let langCode = languageDetector.detect() || defaultLang.code;
 | |
|           if (Array.isArray(langCode)) {
 | |
|             langCode = langCode[0];
 | |
|           }
 | |
|           setLangCode(langCode);
 | |
|           excalidrawAPI.updateScene({
 | |
|             ...localDataState,
 | |
|           });
 | |
|           LibraryIndexedDBAdapter.load().then((data) => {
 | |
|             if (data) {
 | |
|               excalidrawAPI.updateLibrary({
 | |
|                 libraryItems: data.libraryItems,
 | |
|               });
 | |
|             }
 | |
|           });
 | |
|           collabAPI?.setUsername(username || "");
 | |
|         }
 | |
| 
 | |
|         if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
 | |
|           const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
 | |
|           const currFiles = excalidrawAPI.getFiles();
 | |
|           const fileIds =
 | |
|             elements?.reduce((acc, element) => {
 | |
|               if (
 | |
|                 isInitializedImageElement(element) &&
 | |
|                 // only load and update images that aren't already loaded
 | |
|                 !currFiles[element.fileId]
 | |
|               ) {
 | |
|                 return acc.concat(element.fileId);
 | |
|               }
 | |
|               return acc;
 | |
|             }, [] as FileId[]) || [];
 | |
|           if (fileIds.length) {
 | |
|             LocalData.fileStorage
 | |
|               .getFiles(fileIds)
 | |
|               .then(({ loadedFiles, erroredFiles }) => {
 | |
|                 if (loadedFiles.length) {
 | |
|                   excalidrawAPI.addFiles(loadedFiles);
 | |
|                 }
 | |
|                 updateStaleImageStatuses({
 | |
|                   excalidrawAPI,
 | |
|                   erroredFiles,
 | |
|                   elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
 | |
|                 });
 | |
|               });
 | |
|           }
 | |
|         }
 | |
|       }
 | |
|     }, SYNC_BROWSER_TABS_TIMEOUT);
 | |
| 
 | |
|     const onUnload = () => {
 | |
|       LocalData.flushSave();
 | |
|     };
 | |
| 
 | |
|     const visibilityChange = (event: FocusEvent | Event) => {
 | |
|       if (event.type === EVENT.BLUR || document.hidden) {
 | |
|         LocalData.flushSave();
 | |
|       }
 | |
|       if (
 | |
|         event.type === EVENT.VISIBILITY_CHANGE ||
 | |
|         event.type === EVENT.FOCUS
 | |
|       ) {
 | |
|         syncData();
 | |
|       }
 | |
|     };
 | |
| 
 | |
|     window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
 | |
|     window.addEventListener(EVENT.UNLOAD, onUnload, false);
 | |
|     window.addEventListener(EVENT.BLUR, visibilityChange, false);
 | |
|     document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
 | |
|     window.addEventListener(EVENT.FOCUS, visibilityChange, false);
 | |
|     return () => {
 | |
|       window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
 | |
|       window.removeEventListener(EVENT.UNLOAD, onUnload, false);
 | |
|       window.removeEventListener(EVENT.BLUR, visibilityChange, false);
 | |
|       window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
 | |
|       document.removeEventListener(
 | |
|         EVENT.VISIBILITY_CHANGE,
 | |
|         visibilityChange,
 | |
|         false,
 | |
|       );
 | |
|       clearTimeout(titleTimeout);
 | |
|     };
 | |
|   }, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     const unloadHandler = (event: BeforeUnloadEvent) => {
 | |
|       LocalData.flushSave();
 | |
| 
 | |
|       if (
 | |
|         excalidrawAPI &&
 | |
|         LocalData.fileStorage.shouldPreventUnload(
 | |
|           excalidrawAPI.getSceneElements(),
 | |
|         )
 | |
|       ) {
 | |
|         preventUnload(event);
 | |
|       }
 | |
|     };
 | |
|     window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
 | |
|     return () => {
 | |
|       window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
 | |
|     };
 | |
|   }, [excalidrawAPI]);
 | |
| 
 | |
|   useEffect(() => {
 | |
|     languageDetector.cacheUserLanguage(langCode);
 | |
|   }, [langCode]);
 | |
| 
 | |
|   const onChange = (
 | |
|     elements: readonly OrderedExcalidrawElement[],
 | |
|     appState: AppState,
 | |
|     files: BinaryFiles,
 | |
|   ) => {
 | |
|     if (collabAPI?.isCollaborating()) {
 | |
|       collabAPI.syncElements(elements);
 | |
|     }
 | |
| 
 | |
|     // this check is redundant, but since this is a hot path, it's best
 | |
|     // not to evaludate the nested expression every time
 | |
|     if (!LocalData.isSavePaused()) {
 | |
|       LocalData.save(elements, appState, files, () => {
 | |
|         if (excalidrawAPI) {
 | |
|           let didChange = false;
 | |
| 
 | |
|           const elements = excalidrawAPI
 | |
|             .getSceneElementsIncludingDeleted()
 | |
|             .map((element) => {
 | |
|               if (
 | |
|                 LocalData.fileStorage.shouldUpdateImageElementStatus(element)
 | |
|               ) {
 | |
|                 const newElement = newElementWith(element, { status: "saved" });
 | |
|                 if (newElement !== element) {
 | |
|                   didChange = true;
 | |
|                 }
 | |
|                 return newElement;
 | |
|               }
 | |
|               return element;
 | |
|             });
 | |
| 
 | |
|           if (didChange) {
 | |
|             excalidrawAPI.updateScene({
 | |
|               elements,
 | |
|             });
 | |
|           }
 | |
|         }
 | |
|       });
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
 | |
|     null,
 | |
|   );
 | |
| 
 | |
|   const onExportToBackend = async (
 | |
|     exportedElements: readonly NonDeletedExcalidrawElement[],
 | |
|     appState: Partial<AppState>,
 | |
|     files: BinaryFiles,
 | |
|   ) => {
 | |
|     if (exportedElements.length === 0) {
 | |
|       throw new Error(t("alerts.cannotExportEmptyCanvas"));
 | |
|     }
 | |
|     try {
 | |
|       const { url, errorMessage } = await exportToBackend(
 | |
|         exportedElements,
 | |
|         {
 | |
|           ...appState,
 | |
|           viewBackgroundColor: appState.exportBackground
 | |
|             ? appState.viewBackgroundColor
 | |
|             : getDefaultAppState().viewBackgroundColor,
 | |
|         },
 | |
|         files,
 | |
|       );
 | |
| 
 | |
|       if (errorMessage) {
 | |
|         throw new Error(errorMessage);
 | |
|       }
 | |
| 
 | |
|       if (url) {
 | |
|         setLatestShareableLink(url);
 | |
|       }
 | |
|     } catch (error: any) {
 | |
|       if (error.name !== "AbortError") {
 | |
|         const { width, height } = appState;
 | |
|         console.error(error, {
 | |
|           width,
 | |
|           height,
 | |
|           devicePixelRatio: window.devicePixelRatio,
 | |
|         });
 | |
|         throw new Error(error.message);
 | |
|       }
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   const renderCustomStats = (
 | |
|     elements: readonly NonDeletedExcalidrawElement[],
 | |
|     appState: UIAppState,
 | |
|   ) => {
 | |
|     return (
 | |
|       <CustomStats
 | |
|         setToast={(message) => excalidrawAPI!.setToast({ message })}
 | |
|         appState={appState}
 | |
|         elements={elements}
 | |
|       />
 | |
|     );
 | |
|   };
 | |
| 
 | |
|   const isOffline = useAtomValue(isOfflineAtom);
 | |
| 
 | |
|   const onCollabDialogOpen = useCallback(
 | |
|     () => setShareDialogState({ isOpen: true, type: "collaborationOnly" }),
 | |
|     [setShareDialogState],
 | |
|   );
 | |
| 
 | |
|   // browsers generally prevent infinite self-embedding, there are
 | |
|   // cases where it still happens, and while we disallow self-embedding
 | |
|   // by not whitelisting our own origin, this serves as an additional guard
 | |
|   if (isSelfEmbedding) {
 | |
|     return (
 | |
|       <div
 | |
|         style={{
 | |
|           display: "flex",
 | |
|           alignItems: "center",
 | |
|           justifyContent: "center",
 | |
|           textAlign: "center",
 | |
|           height: "100%",
 | |
|         }}
 | |
|       >
 | |
|         <h1>I'm not a pretzel!</h1>
 | |
|       </div>
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   const ExcalidrawPlusCommand = {
 | |
|     label: "Excalidraw+",
 | |
|     category: DEFAULT_CATEGORIES.links,
 | |
|     predicate: true,
 | |
|     icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
 | |
|     keywords: ["plus", "cloud", "server"],
 | |
|     perform: () => {
 | |
|       window.open(
 | |
|         `${
 | |
|           import.meta.env.VITE_APP_PLUS_LP
 | |
|         }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
 | |
|         "_blank",
 | |
|       );
 | |
|     },
 | |
|   };
 | |
|   const ExcalidrawPlusAppCommand = {
 | |
|     label: "Sign up",
 | |
|     category: DEFAULT_CATEGORIES.links,
 | |
|     predicate: true,
 | |
|     icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
 | |
|     keywords: [
 | |
|       "excalidraw",
 | |
|       "plus",
 | |
|       "cloud",
 | |
|       "server",
 | |
|       "signin",
 | |
|       "login",
 | |
|       "signup",
 | |
|     ],
 | |
|     perform: () => {
 | |
|       window.open(
 | |
|         `${
 | |
|           import.meta.env.VITE_APP_PLUS_APP
 | |
|         }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
 | |
|         "_blank",
 | |
|       );
 | |
|     },
 | |
|   };
 | |
| 
 | |
|   return (
 | |
|     <div
 | |
|       style={{ height: "100%" }}
 | |
|       className={clsx("excalidraw-app", {
 | |
|         "is-collaborating": isCollaborating,
 | |
|       })}
 | |
|     >
 | |
|       <Excalidraw
 | |
|         excalidrawAPI={excalidrawRefCallback}
 | |
|         onChange={onChange}
 | |
|         initialData={initialStatePromiseRef.current.promise}
 | |
|         isCollaborating={isCollaborating}
 | |
|         onPointerUpdate={collabAPI?.onPointerUpdate}
 | |
|         UIOptions={{
 | |
|           canvasActions: {
 | |
|             toggleTheme: true,
 | |
|             export: {
 | |
|               onExportToBackend,
 | |
|               renderCustomUI: excalidrawAPI
 | |
|                 ? (elements, appState, files) => {
 | |
|                     return (
 | |
|                       <ExportToExcalidrawPlus
 | |
|                         elements={elements}
 | |
|                         appState={appState}
 | |
|                         files={files}
 | |
|                         name={excalidrawAPI.getName()}
 | |
|                         onError={(error) => {
 | |
|                           excalidrawAPI?.updateScene({
 | |
|                             appState: {
 | |
|                               errorMessage: error.message,
 | |
|                             },
 | |
|                           });
 | |
|                         }}
 | |
|                         onSuccess={() => {
 | |
|                           excalidrawAPI.updateScene({
 | |
|                             appState: { openDialog: null },
 | |
|                           });
 | |
|                         }}
 | |
|                       />
 | |
|                     );
 | |
|                   }
 | |
|                 : undefined,
 | |
|             },
 | |
|           },
 | |
|         }}
 | |
|         langCode={langCode}
 | |
|         renderCustomStats={renderCustomStats}
 | |
|         detectScroll={false}
 | |
|         handleKeyboardGlobally={true}
 | |
|         autoFocus={true}
 | |
|         theme={editorTheme}
 | |
|         renderTopRightUI={(isMobile) => {
 | |
|           if (isMobile || !collabAPI || isCollabDisabled) {
 | |
|             return null;
 | |
|           }
 | |
|           return (
 | |
|             <div className="top-right-ui">
 | |
|               {collabError.message && <CollabError collabError={collabError} />}
 | |
|               <LiveCollaborationTrigger
 | |
|                 isCollaborating={isCollaborating}
 | |
|                 onSelect={() =>
 | |
|                   setShareDialogState({ isOpen: true, type: "share" })
 | |
|                 }
 | |
|               />
 | |
|             </div>
 | |
|           );
 | |
|         }}
 | |
|       >
 | |
|         <AppMainMenu
 | |
|           onCollabDialogOpen={onCollabDialogOpen}
 | |
|           isCollaborating={isCollaborating}
 | |
|           isCollabEnabled={!isCollabDisabled}
 | |
|           theme={appTheme}
 | |
|           setTheme={(theme) => setAppTheme(theme)}
 | |
|         />
 | |
|         <AppWelcomeScreen
 | |
|           onCollabDialogOpen={onCollabDialogOpen}
 | |
|           isCollabEnabled={!isCollabDisabled}
 | |
|         />
 | |
|         <OverwriteConfirmDialog>
 | |
|           <OverwriteConfirmDialog.Actions.ExportToImage />
 | |
|           <OverwriteConfirmDialog.Actions.SaveToDisk />
 | |
|           {excalidrawAPI && (
 | |
|             <OverwriteConfirmDialog.Action
 | |
|               title={t("overwriteConfirm.action.excalidrawPlus.title")}
 | |
|               actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
 | |
|               onClick={() => {
 | |
|                 exportToExcalidrawPlus(
 | |
|                   excalidrawAPI.getSceneElements(),
 | |
|                   excalidrawAPI.getAppState(),
 | |
|                   excalidrawAPI.getFiles(),
 | |
|                   excalidrawAPI.getName(),
 | |
|                 );
 | |
|               }}
 | |
|             >
 | |
|               {t("overwriteConfirm.action.excalidrawPlus.description")}
 | |
|             </OverwriteConfirmDialog.Action>
 | |
|           )}
 | |
|         </OverwriteConfirmDialog>
 | |
|         <AppFooter />
 | |
|         <TTDDialog
 | |
|           onTextSubmit={async (input) => {
 | |
|             try {
 | |
|               const response = await fetch(
 | |
|                 `${
 | |
|                   import.meta.env.VITE_APP_AI_BACKEND
 | |
|                 }/v1/ai/text-to-diagram/generate`,
 | |
|                 {
 | |
|                   method: "POST",
 | |
|                   headers: {
 | |
|                     Accept: "application/json",
 | |
|                     "Content-Type": "application/json",
 | |
|                   },
 | |
|                   body: JSON.stringify({ prompt: input }),
 | |
|                 },
 | |
|               );
 | |
| 
 | |
|               const rateLimit = response.headers.has("X-Ratelimit-Limit")
 | |
|                 ? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10)
 | |
|                 : undefined;
 | |
| 
 | |
|               const rateLimitRemaining = response.headers.has(
 | |
|                 "X-Ratelimit-Remaining",
 | |
|               )
 | |
|                 ? parseInt(
 | |
|                     response.headers.get("X-Ratelimit-Remaining") || "0",
 | |
|                     10,
 | |
|                   )
 | |
|                 : undefined;
 | |
| 
 | |
|               const json = await response.json();
 | |
| 
 | |
|               if (!response.ok) {
 | |
|                 if (response.status === 429) {
 | |
|                   return {
 | |
|                     rateLimit,
 | |
|                     rateLimitRemaining,
 | |
|                     error: new Error(
 | |
|                       "Too many requests today, please try again tomorrow!",
 | |
|                     ),
 | |
|                   };
 | |
|                 }
 | |
| 
 | |
|                 throw new Error(json.message || "Generation failed...");
 | |
|               }
 | |
| 
 | |
|               const generatedResponse = json.generatedResponse;
 | |
|               if (!generatedResponse) {
 | |
|                 throw new Error("Generation failed...");
 | |
|               }
 | |
| 
 | |
|               return { generatedResponse, rateLimit, rateLimitRemaining };
 | |
|             } catch (err: any) {
 | |
|               throw new Error("Request failed");
 | |
|             }
 | |
|           }}
 | |
|         />
 | |
|         <TTDDialogTrigger />
 | |
|         {isCollaborating && isOffline && (
 | |
|           <div className="collab-offline-warning">
 | |
|             {t("alerts.collabOfflineWarning")}
 | |
|           </div>
 | |
|         )}
 | |
|         {latestShareableLink && (
 | |
|           <ShareableLinkDialog
 | |
|             link={latestShareableLink}
 | |
|             onCloseRequest={() => setLatestShareableLink(null)}
 | |
|             setErrorMessage={setErrorMessage}
 | |
|           />
 | |
|         )}
 | |
|         {excalidrawAPI && !isCollabDisabled && (
 | |
|           <Collab excalidrawAPI={excalidrawAPI} />
 | |
|         )}
 | |
| 
 | |
|         <ShareDialog
 | |
|           collabAPI={collabAPI}
 | |
|           onExportToBackend={async () => {
 | |
|             if (excalidrawAPI) {
 | |
|               try {
 | |
|                 await onExportToBackend(
 | |
|                   excalidrawAPI.getSceneElements(),
 | |
|                   excalidrawAPI.getAppState(),
 | |
|                   excalidrawAPI.getFiles(),
 | |
|                 );
 | |
|               } catch (error: any) {
 | |
|                 setErrorMessage(error.message);
 | |
|               }
 | |
|             }
 | |
|           }}
 | |
|         />
 | |
| 
 | |
|         {errorMessage && (
 | |
|           <ErrorDialog onClose={() => setErrorMessage("")}>
 | |
|             {errorMessage}
 | |
|           </ErrorDialog>
 | |
|         )}
 | |
| 
 | |
|         <CommandPalette
 | |
|           customCommandPaletteItems={[
 | |
|             {
 | |
|               label: t("labels.liveCollaboration"),
 | |
|               category: DEFAULT_CATEGORIES.app,
 | |
|               keywords: [
 | |
|                 "team",
 | |
|                 "multiplayer",
 | |
|                 "share",
 | |
|                 "public",
 | |
|                 "session",
 | |
|                 "invite",
 | |
|               ],
 | |
|               icon: usersIcon,
 | |
|               perform: () => {
 | |
|                 setShareDialogState({
 | |
|                   isOpen: true,
 | |
|                   type: "collaborationOnly",
 | |
|                 });
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: t("roomDialog.button_stopSession"),
 | |
|               category: DEFAULT_CATEGORIES.app,
 | |
|               predicate: () => !!collabAPI?.isCollaborating(),
 | |
|               keywords: [
 | |
|                 "stop",
 | |
|                 "session",
 | |
|                 "end",
 | |
|                 "leave",
 | |
|                 "close",
 | |
|                 "exit",
 | |
|                 "collaboration",
 | |
|               ],
 | |
|               perform: () => {
 | |
|                 if (collabAPI) {
 | |
|                   collabAPI.stopCollaboration();
 | |
|                   if (!collabAPI.isCollaborating()) {
 | |
|                     setShareDialogState({ isOpen: false });
 | |
|                   }
 | |
|                 }
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: t("labels.share"),
 | |
|               category: DEFAULT_CATEGORIES.app,
 | |
|               predicate: true,
 | |
|               icon: share,
 | |
|               keywords: [
 | |
|                 "link",
 | |
|                 "shareable",
 | |
|                 "readonly",
 | |
|                 "export",
 | |
|                 "publish",
 | |
|                 "snapshot",
 | |
|                 "url",
 | |
|                 "collaborate",
 | |
|                 "invite",
 | |
|               ],
 | |
|               perform: async () => {
 | |
|                 setShareDialogState({ isOpen: true, type: "share" });
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: "GitHub",
 | |
|               icon: GithubIcon,
 | |
|               category: DEFAULT_CATEGORIES.links,
 | |
|               predicate: true,
 | |
|               keywords: [
 | |
|                 "issues",
 | |
|                 "bugs",
 | |
|                 "requests",
 | |
|                 "report",
 | |
|                 "features",
 | |
|                 "social",
 | |
|                 "community",
 | |
|               ],
 | |
|               perform: () => {
 | |
|                 window.open(
 | |
|                   "https://github.com/excalidraw/excalidraw",
 | |
|                   "_blank",
 | |
|                   "noopener noreferrer",
 | |
|                 );
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: t("labels.followUs"),
 | |
|               icon: XBrandIcon,
 | |
|               category: DEFAULT_CATEGORIES.links,
 | |
|               predicate: true,
 | |
|               keywords: ["twitter", "contact", "social", "community"],
 | |
|               perform: () => {
 | |
|                 window.open(
 | |
|                   "https://x.com/excalidraw",
 | |
|                   "_blank",
 | |
|                   "noopener noreferrer",
 | |
|                 );
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: t("labels.discordChat"),
 | |
|               category: DEFAULT_CATEGORIES.links,
 | |
|               predicate: true,
 | |
|               icon: DiscordIcon,
 | |
|               keywords: [
 | |
|                 "chat",
 | |
|                 "talk",
 | |
|                 "contact",
 | |
|                 "bugs",
 | |
|                 "requests",
 | |
|                 "report",
 | |
|                 "feedback",
 | |
|                 "suggestions",
 | |
|                 "social",
 | |
|                 "community",
 | |
|               ],
 | |
|               perform: () => {
 | |
|                 window.open(
 | |
|                   "https://discord.gg/UexuTaE",
 | |
|                   "_blank",
 | |
|                   "noopener noreferrer",
 | |
|                 );
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               label: "YouTube",
 | |
|               icon: youtubeIcon,
 | |
|               category: DEFAULT_CATEGORIES.links,
 | |
|               predicate: true,
 | |
|               keywords: ["features", "tutorials", "howto", "help", "community"],
 | |
|               perform: () => {
 | |
|                 window.open(
 | |
|                   "https://youtube.com/@excalidraw",
 | |
|                   "_blank",
 | |
|                   "noopener noreferrer",
 | |
|                 );
 | |
|               },
 | |
|             },
 | |
|             ...(isExcalidrawPlusSignedUser
 | |
|               ? [
 | |
|                   {
 | |
|                     ...ExcalidrawPlusAppCommand,
 | |
|                     label: "Sign in / Go to Excalidraw+",
 | |
|                   },
 | |
|                 ]
 | |
|               : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
 | |
| 
 | |
|             {
 | |
|               label: t("overwriteConfirm.action.excalidrawPlus.button"),
 | |
|               category: DEFAULT_CATEGORIES.export,
 | |
|               icon: exportToPlus,
 | |
|               predicate: true,
 | |
|               keywords: ["plus", "export", "save", "backup"],
 | |
|               perform: () => {
 | |
|                 if (excalidrawAPI) {
 | |
|                   exportToExcalidrawPlus(
 | |
|                     excalidrawAPI.getSceneElements(),
 | |
|                     excalidrawAPI.getAppState(),
 | |
|                     excalidrawAPI.getFiles(),
 | |
|                     excalidrawAPI.getName(),
 | |
|                   );
 | |
|                 }
 | |
|               },
 | |
|             },
 | |
|             {
 | |
|               ...CommandPalette.defaultItems.toggleTheme,
 | |
|               perform: () => {
 | |
|                 setAppTheme(
 | |
|                   editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
 | |
|                 );
 | |
|               },
 | |
|             },
 | |
|           ]}
 | |
|         />
 | |
|       </Excalidraw>
 | |
|     </div>
 | |
|   );
 | |
| };
 | |
| 
 | |
| const ExcalidrawApp = () => {
 | |
|   return (
 | |
|     <TopErrorBoundary>
 | |
|       <Provider unstable_createStore={() => appJotaiStore}>
 | |
|         <ExcalidrawWrapper />
 | |
|       </Provider>
 | |
|     </TopErrorBoundary>
 | |
|   );
 | |
| };
 | |
| 
 | |
| export default ExcalidrawApp;
 | 
