mirror of
				https://github.com/excalidraw/excalidraw.git
				synced 2025-11-04 12:54:23 +01:00 
			
		
		
		
	* feat: decouple package deps and introduce yarn workspaces * update root directory * fix * fix scripts * fix lint * update path in scripts * remove yarn.lock files from packages * ignore workspace * dummy * dummy * remove comment check * revert workflow changes * ignore ws when installing gh actions * remove log * update path * fix * fix typo
		
			
				
	
	
		
			872 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			872 lines
		
	
	
		
			27 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
import polyfill from "../packages/excalidraw/polyfill";
 | 
						|
import LanguageDetector from "i18next-browser-languagedetector";
 | 
						|
import { 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 {
 | 
						|
  ExcalidrawElement,
 | 
						|
  FileId,
 | 
						|
  NonDeletedExcalidrawElement,
 | 
						|
  Theme,
 | 
						|
} 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,
 | 
						|
  LibraryItems,
 | 
						|
  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,
 | 
						|
  STORAGE_KEYS,
 | 
						|
  SYNC_BROWSER_TABS_TIMEOUT,
 | 
						|
} from "./app_constants";
 | 
						|
import Collab, {
 | 
						|
  CollabAPI,
 | 
						|
  collabAPIAtom,
 | 
						|
  collabDialogShownAtom,
 | 
						|
  isCollaboratingAtom,
 | 
						|
  isOfflineAtom,
 | 
						|
} from "./collab/Collab";
 | 
						|
import {
 | 
						|
  exportToBackend,
 | 
						|
  getCollaborationLinkData,
 | 
						|
  isCollaborationLink,
 | 
						|
  loadScene,
 | 
						|
} from "./data";
 | 
						|
import {
 | 
						|
  getLibraryItemsFromStorage,
 | 
						|
  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 { LocalData } from "./data/LocalData";
 | 
						|
import { isBrowserStorageStateNewer } from "./data/tabSync";
 | 
						|
import clsx from "clsx";
 | 
						|
import { reconcileElements } from "./collab/reconciliation";
 | 
						|
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";
 | 
						|
 | 
						|
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(),
 | 
						|
          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();
 | 
						|
 | 
						|
  // 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 [collabAPI] = useAtom(collabAPIAtom);
 | 
						|
  const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
 | 
						|
  const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
 | 
						|
    return isCollaborationLink(window.location.href);
 | 
						|
  });
 | 
						|
 | 
						|
  useHandleLibrary({
 | 
						|
    excalidrawAPI,
 | 
						|
    getInitialLibraryItems: getLibraryItemsFromStorage,
 | 
						|
  });
 | 
						|
 | 
						|
  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,
 | 
						|
          });
 | 
						|
          excalidrawAPI.updateLibrary({
 | 
						|
            libraryItems: getLibraryItemsFromStorage(),
 | 
						|
          });
 | 
						|
          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 [theme, setTheme] = useState<Theme>(
 | 
						|
    () =>
 | 
						|
      (localStorage.getItem(
 | 
						|
        STORAGE_KEYS.LOCAL_STORAGE_THEME,
 | 
						|
      ) as Theme | null) ||
 | 
						|
      // FIXME migration from old LS scheme. Can be removed later. #5660
 | 
						|
      importFromLocalStorage().appState?.theme ||
 | 
						|
      THEME.LIGHT,
 | 
						|
  );
 | 
						|
 | 
						|
  useEffect(() => {
 | 
						|
    localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
 | 
						|
    // currently only used for body styling during init (see public/index.html),
 | 
						|
    // but may change in the future
 | 
						|
    document.documentElement.classList.toggle("dark", theme === THEME.DARK);
 | 
						|
  }, [theme]);
 | 
						|
 | 
						|
  const onChange = (
 | 
						|
    elements: readonly ExcalidrawElement[],
 | 
						|
    appState: AppState,
 | 
						|
    files: BinaryFiles,
 | 
						|
  ) => {
 | 
						|
    if (collabAPI?.isCollaborating()) {
 | 
						|
      collabAPI.syncElements(elements);
 | 
						|
    }
 | 
						|
 | 
						|
    setTheme(appState.theme);
 | 
						|
 | 
						|
    // 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,
 | 
						|
    canvas: HTMLCanvasElement,
 | 
						|
  ) => {
 | 
						|
    if (exportedElements.length === 0) {
 | 
						|
      throw new Error(t("alerts.cannotExportEmptyCanvas"));
 | 
						|
    }
 | 
						|
    if (canvas) {
 | 
						|
      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 } = canvas;
 | 
						|
          console.error(error, { width, height });
 | 
						|
          throw new Error(error.message);
 | 
						|
        }
 | 
						|
      }
 | 
						|
    }
 | 
						|
  };
 | 
						|
 | 
						|
  const renderCustomStats = (
 | 
						|
    elements: readonly NonDeletedExcalidrawElement[],
 | 
						|
    appState: UIAppState,
 | 
						|
  ) => {
 | 
						|
    return (
 | 
						|
      <CustomStats
 | 
						|
        setToast={(message) => excalidrawAPI!.setToast({ message })}
 | 
						|
        appState={appState}
 | 
						|
        elements={elements}
 | 
						|
      />
 | 
						|
    );
 | 
						|
  };
 | 
						|
 | 
						|
  const onLibraryChange = async (items: LibraryItems) => {
 | 
						|
    if (!items.length) {
 | 
						|
      localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
 | 
						|
      return;
 | 
						|
    }
 | 
						|
    const serializedItems = JSON.stringify(items);
 | 
						|
    localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
 | 
						|
  };
 | 
						|
 | 
						|
  const isOffline = useAtomValue(isOfflineAtom);
 | 
						|
 | 
						|
  // 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>
 | 
						|
    );
 | 
						|
  }
 | 
						|
 | 
						|
  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: (elements, appState, files) => {
 | 
						|
                return (
 | 
						|
                  <ExportToExcalidrawPlus
 | 
						|
                    elements={elements}
 | 
						|
                    appState={appState}
 | 
						|
                    files={files}
 | 
						|
                    onError={(error) => {
 | 
						|
                      excalidrawAPI?.updateScene({
 | 
						|
                        appState: {
 | 
						|
                          errorMessage: error.message,
 | 
						|
                        },
 | 
						|
                      });
 | 
						|
                    }}
 | 
						|
                    onSuccess={() => {
 | 
						|
                      excalidrawAPI?.updateScene({
 | 
						|
                        appState: { openDialog: null },
 | 
						|
                      });
 | 
						|
                    }}
 | 
						|
                  />
 | 
						|
                );
 | 
						|
              },
 | 
						|
            },
 | 
						|
          },
 | 
						|
        }}
 | 
						|
        langCode={langCode}
 | 
						|
        renderCustomStats={renderCustomStats}
 | 
						|
        detectScroll={false}
 | 
						|
        handleKeyboardGlobally={true}
 | 
						|
        onLibraryChange={onLibraryChange}
 | 
						|
        autoFocus={true}
 | 
						|
        theme={theme}
 | 
						|
        renderTopRightUI={(isMobile) => {
 | 
						|
          if (isMobile || !collabAPI || isCollabDisabled) {
 | 
						|
            return null;
 | 
						|
          }
 | 
						|
          return (
 | 
						|
            <LiveCollaborationTrigger
 | 
						|
              isCollaborating={isCollaborating}
 | 
						|
              onSelect={() => setCollabDialogShown(true)}
 | 
						|
            />
 | 
						|
          );
 | 
						|
        }}
 | 
						|
      >
 | 
						|
        <AppMainMenu
 | 
						|
          setCollabDialogShown={setCollabDialogShown}
 | 
						|
          isCollaborating={isCollaborating}
 | 
						|
          isCollabEnabled={!isCollabDisabled}
 | 
						|
        />
 | 
						|
        <AppWelcomeScreen
 | 
						|
          setCollabDialogShown={setCollabDialogShown}
 | 
						|
          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(),
 | 
						|
                );
 | 
						|
              }}
 | 
						|
            >
 | 
						|
              {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} />
 | 
						|
        )}
 | 
						|
        {errorMessage && (
 | 
						|
          <ErrorDialog onClose={() => setErrorMessage("")}>
 | 
						|
            {errorMessage}
 | 
						|
          </ErrorDialog>
 | 
						|
        )}
 | 
						|
      </Excalidraw>
 | 
						|
    </div>
 | 
						|
  );
 | 
						|
};
 | 
						|
 | 
						|
const ExcalidrawApp = () => {
 | 
						|
  return (
 | 
						|
    <TopErrorBoundary>
 | 
						|
      <Provider unstable_createStore={() => appJotaiStore}>
 | 
						|
        <ExcalidrawWrapper />
 | 
						|
      </Provider>
 | 
						|
    </TopErrorBoundary>
 | 
						|
  );
 | 
						|
};
 | 
						|
 | 
						|
export default ExcalidrawApp;
 |