|
@@ -0,0 +1,900 @@
|
|
|
+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 { useMathSubtype } from "../packages/excalidraw/element/subtypes/mathjax";
|
|
|
+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,
|
|
|
+ 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";
|
|
|
+import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
|
|
|
+
|
|
|
+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>();
|
|
|
+
|
|
|
+ useMathSubtype(excalidrawAPI);
|
|
|
+
|
|
|
+ const [, setShareDialogState] = useAtom(shareDialogStateAtom);
|
|
|
+ const [collabAPI] = useAtom(collabAPIAtom);
|
|
|
+ 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,
|
|
|
+ ) => {
|
|
|
+ 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 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);
|
|
|
+
|
|
|
+ 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>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ 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={() =>
|
|
|
+ setShareDialogState({ isOpen: true, type: "share" })
|
|
|
+ }
|
|
|
+ />
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ <AppMainMenu
|
|
|
+ onCollabDialogOpen={onCollabDialogOpen}
|
|
|
+ isCollaborating={isCollaborating}
|
|
|
+ isCollabEnabled={!isCollabDisabled}
|
|
|
+ />
|
|
|
+ <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(),
|
|
|
+ );
|
|
|
+ }}
|
|
|
+ >
|
|
|
+ {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>
|
|
|
+ )}
|
|
|
+ </Excalidraw>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+const ExcalidrawApp = () => {
|
|
|
+ return (
|
|
|
+ <TopErrorBoundary>
|
|
|
+ <Provider unstable_createStore={() => appJotaiStore}>
|
|
|
+ <ExcalidrawWrapper />
|
|
|
+ </Provider>
|
|
|
+ </TopErrorBoundary>
|
|
|
+ );
|
|
|
+};
|
|
|
+
|
|
|
+export default ExcalidrawApp;
|