12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160 |
- import {
- Excalidraw,
- LiveCollaborationTrigger,
- TTDDialogTrigger,
- CaptureUpdateAction,
- reconcileElements,
- } from "@excalidraw/excalidraw";
- import { trackEvent } from "@excalidraw/excalidraw/analytics";
- import { getDefaultAppState } from "@excalidraw/excalidraw/appState";
- import {
- CommandPalette,
- DEFAULT_CATEGORIES,
- } from "@excalidraw/excalidraw/components/CommandPalette/CommandPalette";
- import { ErrorDialog } from "@excalidraw/excalidraw/components/ErrorDialog";
- import { OverwriteConfirmDialog } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirm";
- import { openConfirmModal } from "@excalidraw/excalidraw/components/OverwriteConfirm/OverwriteConfirmState";
- import { ShareableLinkDialog } from "@excalidraw/excalidraw/components/ShareableLinkDialog";
- import Trans from "@excalidraw/excalidraw/components/Trans";
- import {
- APP_NAME,
- EVENT,
- THEME,
- TITLE_TIMEOUT,
- VERSION_TIMEOUT,
- debounce,
- getVersion,
- getFrame,
- isTestEnv,
- preventUnload,
- resolvablePromise,
- isRunningInIframe,
- isDevEnv,
- } from "@excalidraw/common";
- import polyfill from "@excalidraw/excalidraw/polyfill";
- import { useCallback, useEffect, useRef, useState } from "react";
- import { loadFromBlob } from "@excalidraw/excalidraw/data/blob";
- import { useCallbackRefState } from "@excalidraw/excalidraw/hooks/useCallbackRefState";
- import { t } from "@excalidraw/excalidraw/i18n";
- import {
- GithubIcon,
- XBrandIcon,
- DiscordIcon,
- ExcalLogo,
- usersIcon,
- exportToPlus,
- share,
- youtubeIcon,
- } from "@excalidraw/excalidraw/components/icons";
- import { isElementLink } from "@excalidraw/element/elementLink";
- import { restore, restoreAppState } from "@excalidraw/excalidraw/data/restore";
- import { newElementWith } from "@excalidraw/element/mutateElement";
- import { isInitializedImageElement } from "@excalidraw/element/typeChecks";
- import clsx from "clsx";
- import {
- parseLibraryTokensFromUrl,
- useHandleLibrary,
- } from "@excalidraw/excalidraw/data/library";
- import type { RemoteExcalidrawElement } from "@excalidraw/excalidraw/data/reconcile";
- import type { RestoredDataState } from "@excalidraw/excalidraw/data/restore";
- import type {
- FileId,
- NonDeletedExcalidrawElement,
- OrderedExcalidrawElement,
- } from "@excalidraw/element/types";
- import type {
- AppState,
- ExcalidrawImperativeAPI,
- BinaryFiles,
- ExcalidrawInitialDataState,
- UIAppState,
- } from "@excalidraw/excalidraw/types";
- import type { ResolutionType } from "@excalidraw/common/utility-types";
- import type { ResolvablePromise } from "@excalidraw/common/utils";
- import CustomStats from "./CustomStats";
- import {
- Provider,
- useAtom,
- useAtomValue,
- useAtomWithInitialValue,
- appJotaiStore,
- } from "./app-jotai";
- import {
- FIREBASE_STORAGE_PREFIXES,
- isExcalidrawPlusSignedUser,
- STORAGE_KEYS,
- SYNC_BROWSER_TABS_TIMEOUT,
- } from "./app_constants";
- import Collab, {
- collabAPIAtom,
- isCollaboratingAtom,
- isOfflineAtom,
- } from "./collab/Collab";
- import { AppFooter } from "./components/AppFooter";
- import { AppMainMenu } from "./components/AppMainMenu";
- import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
- import {
- ExportToExcalidrawPlus,
- exportToExcalidrawPlus,
- } from "./components/ExportToExcalidrawPlus";
- import { TopErrorBoundary } from "./components/TopErrorBoundary";
- import {
- exportToBackend,
- getCollaborationLinkData,
- isCollaborationLink,
- loadScene,
- } from "./data";
- import { updateStaleImageStatuses } from "./data/FileManager";
- import {
- importFromLocalStorage,
- importUsernameFromLocalStorage,
- } from "./data/localStorage";
- import { loadFilesFromFirebase } from "./data/firebase";
- import {
- LibraryIndexedDBAdapter,
- LibraryLocalStorageMigrationAdapter,
- LocalData,
- } from "./data/LocalData";
- import { isBrowserStorageStateNewer } from "./data/tabSync";
- import { ShareDialog, shareDialogStateAtom } from "./share/ShareDialog";
- import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
- 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 "./index.scss";
- import type { CollabAPI } from "./collab/Collab";
- 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 (isDevEnv()) {
- 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: "Excalidraw+",
- category: DEFAULT_CATEGORIES.links,
- predicate: true,
- icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
- keywords: ["plus", "cloud", "server"],
- perform: () => {
- window.open(
- `${
- import.meta.env.VITE_APP_PLUS_LP
- }/plus?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
- "_blank",
- );
- },
- };
- const ExcalidrawPlusAppCommand = {
- label: "Sign up",
- category: DEFAULT_CATEGORIES.links,
- predicate: true,
- icon: <div style={{ width: 14 }}>{ExcalLogo}</div>,
- keywords: [
- "excalidraw",
- "plus",
- "cloud",
- "server",
- "signin",
- "login",
- "signup",
- ],
- perform: () => {
- window.open(
- `${
- import.meta.env.VITE_APP_PLUS_APP
- }?utm_source=excalidraw&utm_medium=app&utm_content=command_palette`,
- "_blank",
- );
- },
- };
- return (
- <div
- style={{ height: "100%" }}
- className={clsx("excalidraw-app", {
- "is-collaborating": isCollaborating,
- })}
- >
- <Excalidraw
- excalidrawAPI={excalidrawRefCallback}
- onChange={onChange}
- initialData={initialStatePromiseRef.current.promise}
- isCollaborating={isCollaborating}
- onPointerUpdate={collabAPI?.onPointerUpdate}
- UIOptions={{
- canvasActions: {
- toggleTheme: true,
- export: {
- onExportToBackend,
- renderCustomUI: excalidrawAPI
- ? (elements, appState, files) => {
- return (
- <ExportToExcalidrawPlus
- elements={elements}
- appState={appState}
- files={files}
- name={excalidrawAPI.getName()}
- onError={(error) => {
- excalidrawAPI?.updateScene({
- appState: {
- errorMessage: error.message,
- },
- });
- }}
- onSuccess={() => {
- excalidrawAPI.updateScene({
- appState: { openDialog: null },
- });
- }}
- />
- );
- }
- : undefined,
- },
- },
- }}
- langCode={langCode}
- renderCustomStats={renderCustomStats}
- detectScroll={false}
- handleKeyboardGlobally={true}
- autoFocus={true}
- theme={editorTheme}
- renderTopRightUI={(isMobile) => {
- if (isMobile || !collabAPI || isCollabDisabled) {
- return null;
- }
- return (
- <div className="top-right-ui">
- {collabError.message && <CollabError collabError={collabError} />}
- <LiveCollaborationTrigger
- isCollaborating={isCollaborating}
- onSelect={() =>
- setShareDialogState({ isOpen: true, type: "share" })
- }
- />
- </div>
- );
- }}
- 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 Excalidraw+",
- },
- ]
- : [ExcalidrawPlusCommand, ExcalidrawPlusAppCommand]),
- {
- label: t("overwriteConfirm.action.excalidrawPlus.button"),
- category: DEFAULT_CATEGORIES.export,
- icon: exportToPlus,
- predicate: true,
- keywords: ["plus", "export", "save", "backup"],
- perform: () => {
- if (excalidrawAPI) {
- exportToExcalidrawPlus(
- excalidrawAPI.getSceneElements(),
- excalidrawAPI.getAppState(),
- excalidrawAPI.getFiles(),
- excalidrawAPI.getName(),
- );
- }
- },
- },
- {
- ...CommandPalette.defaultItems.toggleTheme,
- perform: () => {
- setAppTheme(
- editorTheme === THEME.DARK ? THEME.LIGHT : THEME.DARK,
- );
- },
- },
- {
- 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;
|