diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d42f8f63..f922f5e7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: - name: Install and lint run: | - yarn --frozen-lockfile + yarn install:deps yarn test:other yarn test:code yarn test:typecheck diff --git a/.github/workflows/test-coverage-pr.yml b/.github/workflows/test-coverage-pr.yml index 76c81829..7d77d39f 100644 --- a/.github/workflows/test-coverage-pr.yml +++ b/.github/workflows/test-coverage-pr.yml @@ -16,7 +16,7 @@ jobs: with: node-version: "18.x" - name: "Install Deps" - run: yarn --frozen-lockfile + run: yarn install:deps - name: "Test Coverage" run: yarn test:coverage - name: "Report Coverage" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5c4584e8..124cae26 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,5 +13,5 @@ jobs: node-version: 18.x - name: Install and test run: | - yarn --frozen-lockfile + yarn install:deps yarn test:app diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx new file mode 100644 index 00000000..e2833efb --- /dev/null +++ b/excalidraw-app/App.tsx @@ -0,0 +1,871 @@ +import polyfill from "../src/polyfill"; +import LanguageDetector from "i18next-browser-languagedetector"; +import { useEffect, useRef, useState } from "react"; +import { trackEvent } from "../src/analytics"; +import { getDefaultAppState } from "../src/appState"; +import { ErrorDialog } from "../src/components/ErrorDialog"; +import { TopErrorBoundary } from "./components/TopErrorBoundary"; +import { + APP_NAME, + EVENT, + THEME, + TITLE_TIMEOUT, + VERSION_TIMEOUT, +} from "../src/constants"; +import { loadFromBlob } from "../src/data/blob"; +import { + ExcalidrawElement, + FileId, + NonDeletedExcalidrawElement, + Theme, +} from "../src/element/types"; +import { useCallbackRefState } from "../src/hooks/useCallbackRefState"; +import { t } from "../src/i18n"; +import { + Excalidraw, + defaultLang, + LiveCollaborationTrigger, + TTDDialog, + TTDDialogTrigger, +} from "../src/packages/excalidraw/index"; +import { + AppState, + LibraryItems, + ExcalidrawImperativeAPI, + BinaryFiles, + ExcalidrawInitialDataState, + UIAppState, +} from "../src/types"; +import { + debounce, + getVersion, + getFrame, + isTestEnv, + preventUnload, + ResolvablePromise, + resolvablePromise, + isRunningInIframe, +} from "../src/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 "../src/data/restore"; +import { + ExportToExcalidrawPlus, + exportToExcalidrawPlus, +} from "./components/ExportToExcalidrawPlus"; +import { updateStaleImageStatuses } from "./data/FileManager"; +import { newElementWith } from "../src/element/mutateElement"; +import { isInitializedImageElement } from "../src/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 "../src/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 "../src/jotai"; +import { appJotaiStore } from "./app-jotai"; + +import "./index.scss"; +import { ResolutionType } from "../src/utility-types"; +import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog"; +import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState"; +import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm"; +import Trans from "../src/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: ( + {text}} + 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; + }>({ promise: null! }); + if (!initialStatePromiseRef.current.promise) { + initialStatePromiseRef.current.promise = + resolvablePromise(); + } + + 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(); + + 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, + 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( + () => + (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( + null, + ); + + const onExportToBackend = async ( + exportedElements: readonly NonDeletedExcalidrawElement[], + appState: Partial, + 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 ( + 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 ( +
+

I'm not a pretzel!

+
+ ); + } + + return ( +
+ { + return ( + { + 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 ( + setCollabDialogShown(true)} + /> + ); + }} + > + + + + + + {excalidrawAPI && ( + { + exportToExcalidrawPlus( + excalidrawAPI.getSceneElements(), + excalidrawAPI.getAppState(), + excalidrawAPI.getFiles(), + ); + }} + > + {t("overwriteConfirm.action.excalidrawPlus.description")} + + )} + + + { + try { + const response = await fetch( + `${ + import.meta.env.VITE_APP_GIT_SHA + }/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"); + } + }} + /> + + {isCollaborating && isOffline && ( +
+ {t("alerts.collabOfflineWarning")} +
+ )} + {latestShareableLink && ( + setLatestShareableLink(null)} + setErrorMessage={setErrorMessage} + /> + )} + {excalidrawAPI && !isCollabDisabled && ( + + )} + {errorMessage && ( + setErrorMessage("")}> + {errorMessage} + + )} +
+
+ ); +}; + +const ExcalidrawApp = () => { + return ( + + appJotaiStore}> + + + + ); +}; + +export default ExcalidrawApp; diff --git a/src/bug-issue-template.js b/excalidraw-app/bug-issue-template.js similarity index 100% rename from src/bug-issue-template.js rename to excalidraw-app/bug-issue-template.js diff --git a/excalidraw-app/components/LanguageList.tsx b/excalidraw-app/components/LanguageList.tsx index 11d4b6d0..74c14384 100644 --- a/excalidraw-app/components/LanguageList.tsx +++ b/excalidraw-app/components/LanguageList.tsx @@ -1,6 +1,6 @@ import { useSetAtom } from "jotai"; import React from "react"; -import { appLangCodeAtom } from ".."; +import { appLangCodeAtom } from "../App"; import { useI18n } from "../../src/i18n"; import { languages } from "../../src/i18n"; diff --git a/src/components/TopErrorBoundary.tsx b/excalidraw-app/components/TopErrorBoundary.tsx similarity index 97% rename from src/components/TopErrorBoundary.tsx rename to excalidraw-app/components/TopErrorBoundary.tsx index d465514f..25d8c5f2 100644 --- a/src/components/TopErrorBoundary.tsx +++ b/excalidraw-app/components/TopErrorBoundary.tsx @@ -1,7 +1,7 @@ import React from "react"; import * as Sentry from "@sentry/browser"; -import { t } from "../i18n"; -import Trans from "./Trans"; +import { t } from "../../src/i18n"; +import Trans from "../../src/components/Trans"; interface TopErrorBoundaryState { hasError: boolean; diff --git a/excalidraw-app/global.d.ts b/excalidraw-app/global.d.ts new file mode 100644 index 00000000..1ce68458 --- /dev/null +++ b/excalidraw-app/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + __EXCALIDRAW_SHA__: string | undefined; +} diff --git a/index.html b/excalidraw-app/index.html similarity index 99% rename from index.html rename to excalidraw-app/index.html index b1e0f2ab..c11d9ab6 100644 --- a/index.html +++ b/excalidraw-app/index.html @@ -195,7 +195,7 @@

Excalidraw

- + <% if ("%VITE_APP_DEV_DISABLE_LIVE_RELOAD%" !== 'true') { %>