diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | bfc2cec7d43eb8eaa46dd3f91084932381257059 (patch) | |
| tree | 0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/App.tsx | |
| parent | 225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff) | |
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/App.tsx')
| -rw-r--r-- | excalidraw-app/App.tsx | 1154 |
1 files changed, 1154 insertions, 0 deletions
diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx new file mode 100644 index 0000000..3d413f0 --- /dev/null +++ b/excalidraw-app/App.tsx @@ -0,0 +1,1154 @@ +import polyfill from "@excalidraw/excalidraw/polyfill"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { trackEvent } from "@excalidraw/excalidraw/analytics"; +import { getDefaultAppState } from "@excalidraw/excalidraw/appState"; +import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog"; +import { TopErrorBoundary } from "./components/TopErrorBoundary"; +import { + APP_NAME, + EVENT, + THEME, + TITLE_TIMEOUT, + VERSION_TIMEOUT, +} from "@excalidraw/excalidraw/constants"; +import { loadFromBlob } from "@excalidraw/excalidraw/data/blob"; +import type { + FileId, + NonDeletedExcalidrawElement, + OrderedExcalidrawElement, +} from "@excalidraw/excalidraw/element/types"; +import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState"; +import { t } from "@excalidraw/excalidraw/i18n"; +import { + Excalidraw, + LiveCollaborationTrigger, + TTDDialogTrigger, + CaptureUpdateAction, + reconcileElements, +} from "@excalidraw/excalidraw"; +import type { + AppState, + ExcalidrawImperativeAPI, + BinaryFiles, + ExcalidrawInitialDataState, + UIAppState, +} from "@excalidraw/excalidraw/types"; +import type { ResolvablePromise } from "@excalidraw/excalidraw/utils"; +import { + debounce, + getVersion, + getFrame, + isTestEnv, + preventUnload, + resolvablePromise, + isRunningInIframe, +} from "@excalidraw/excalidraw/utils"; +import { + FIREBASE_STORAGE_PREFIXES, + isExcalidrawPlusSignedUser, + STORAGE_KEYS, + SYNC_BROWSER_TABS_TIMEOUT, +} from "./app_constants"; +import type { CollabAPI } from "./collab/Collab"; +import Collab, { + collabAPIAtom, + isCollaboratingAtom, + isOfflineAtom, +} from "./collab/Collab"; +import { + exportToBackend, + getCollaborationLinkData, + isCollaborationLink, + loadScene, +} from "./data"; +import { + importFromLocalStorage, + importUsernameFromLocalStorage, +} from "./data/localStorage"; +import CustomStats from "./CustomStats"; +import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore"; +import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore"; +import { + ExportToExcalidrawPlus, + exportToExcalidrawPlus, +} from "./components/ExportToExcalidrawPlus"; +import { updateStaleImageStatuses } from "./data/FileManager"; +import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; +import { isInitializedImageElement } from "@excalidraw/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 "@excalidraw/excalidraw/data/library"; +import { AppMainMenu } from "./components/AppMainMenu"; +import { AppWelcomeScreen } from "./components/AppWelcomeScreen"; +import { AppFooter } from "./components/AppFooter"; +import { + Provider, + useAtom, + useAtomValue, + useAtomWithInitialValue, + appJotaiStore, +} from "./app-jotai"; + +import "./index.scss"; +import type { ResolutionType } from "@excalidraw/excalidraw/utility-types"; +import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog"; +import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState"; +import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm"; +import Trans from "@excalidraw/excalidraw/components/Trans"; +import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog"; +import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError"; +import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile"; +import { + CommandPalette, + DEFAULT_CATEGORIES, +} from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette"; +import { + GithubIcon, + XBrandIcon, + DiscordIcon, + ExcalLogo, + usersIcon, + exportToPlus, + share, + youtubeIcon, +} from "@excalidraw/excalidraw/components/icons"; +import { useHandleAppTheme } from "./useHandleAppTheme"; +import { getPreferredLanguage } from "./app-language/language-detector"; +import { useAppLangCode } from "./app-language/language-state"; +import DebugCanvas, { + debugRenderer, + isVisualDebuggerEnabled, + loadSavedDebugState, +} from "./components/DebugCanvas"; +import { AIComponents } from "./components/AI"; +import { ExcalidrawPlusIframeExport } from "./ExcalidrawPlusIframeExport"; +import { isElementLink } from "@excalidraw/excalidraw/element/elementLink"; + +polyfill(); + +window.EXCALIDRAW_THROTTLE_RENDER = true; + +declare global { + interface BeforeInstallPromptEventChoiceResult { + outcome: "accepted" | "dismissed"; + } + + interface BeforeInstallPromptEvent extends Event { + prompt(): Promise<void>; + userChoice: Promise<BeforeInstallPromptEventChoiceResult>; + } + + interface WindowEventMap { + beforeinstallprompt: BeforeInstallPromptEvent; + } +} + +let pwaEvent: BeforeInstallPromptEvent | null = null; + +// Adding a listener outside of the component as it may (?) need to be +// subscribed early to catch the event. +// +// Also note that it will fire only if certain heuristics are met (user has +// used the app for some time, etc.) +window.addEventListener( + "beforeinstallprompt", + (event: BeforeInstallPromptEvent) => { + // prevent Chrome <= 67 from automatically showing the prompt + event.preventDefault(); + // cache for later use + pwaEvent = event; + }, +); + +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 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 ExcalidrawWrapper = () => { + const [errorMessage, setErrorMessage] = useState(""); + const isCollabDisabled = isRunningInIframe(); + + const { editorTheme, appTheme, setAppTheme } = useHandleAppTheme(); + + const [langCode, setLangCode] = useAppLangCode(); + + // initial state + // --------------------------------------------------------------------------- + + const initialStatePromiseRef = useRef<{ + promise: ResolvablePromise<ExcalidrawInitialDataState | null>; + }>({ promise: null! }); + if (!initialStatePromiseRef.current.promise) { + initialStatePromiseRef.current.promise = + resolvablePromise<ExcalidrawInitialDataState | null>(); + } + + const debugCanvasRef = useRef<HTMLCanvasElement>(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, + }); + + const [, forceRefresh] = useState(false); + + useEffect(() => { + if (import.meta.env.DEV) { + const debugState = loadSavedDebugState(); + + if (debugState.enabled && !window.visualDebug) { + window.visualDebug = { + data: [], + }; + } else { + delete window.visualDebug; + } + forceRefresh((prev) => !prev); + } + }, [excalidrawAPI]); + + 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 }), + captureUpdate: CaptureUpdateAction.IMMEDIATELY, + }); + } + }); + } + }; + + 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(); + setLangCode(getPreferredLanguage()); + excalidrawAPI.updateScene({ + ...localDataState, + captureUpdate: CaptureUpdateAction.NEVER, + }); + 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]); + + 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, + captureUpdate: CaptureUpdateAction.NEVER, + }); + } + } + }); + } + + // Render the debug scene if the debug canvas is available + if (debugCanvasRef.current && excalidrawAPI) { + debugRenderer( + debugCanvasRef.current, + appState, + window.devicePixelRatio, + () => forceRefresh((prev) => !prev), + ); + } + }; + + 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: "kj-diagramming cloud", + 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: [ + "diagramming", + "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> + ); + }} + onLinkOpen={(element, event) => { + if (element.link && isElementLink(element.link)) { + event.preventDefault(); + excalidrawAPI?.scrollToContent(element.link, { animate: true }); + } + }} + > + <AppMainMenu + onCollabDialogOpen={onCollabDialogOpen} + isCollaborating={isCollaborating} + isCollabEnabled={!isCollabDisabled} + theme={appTheme} + setTheme={(theme) => setAppTheme(theme)} + refresh={() => forceRefresh((prev) => !prev)} + /> + <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 onChange={() => excalidrawAPI?.refresh()} /> + {excalidrawAPI && <AIComponents excalidrawAPI={excalidrawAPI} />} + + <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 kj-diagramming cloud", + }, + ] + : [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, + ); + }, + }, + { + label: t("labels.installPWA"), + category: DEFAULT_CATEGORIES.app, + predicate: () => !!pwaEvent, + perform: () => { + if (pwaEvent) { + pwaEvent.prompt(); + pwaEvent.userChoice.then(() => { + // event cannot be reused, but we'll hopefully + // grab new one as the event should be fired again + pwaEvent = null; + }); + } + }, + }, + ]} + /> + {isVisualDebuggerEnabled() && excalidrawAPI && ( + <DebugCanvas + appState={excalidrawAPI.getAppState()} + scale={window.devicePixelRatio} + ref={debugCanvasRef} + /> + )} + </Excalidraw> + </div> + ); +}; + +const ExcalidrawApp = () => { + const isCloudExportWindow = + window.location.pathname === "/excalidraw-plus-export"; + if (isCloudExportWindow) { + return <ExcalidrawPlusIframeExport />; + } + + return ( + <TopErrorBoundary> + <Provider store={appJotaiStore}> + <ExcalidrawWrapper /> + </Provider> + </TopErrorBoundary> + ); +}; + +export default ExcalidrawApp; |
