mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-02 03:25:53 +01:00
ea7c702cfc
Add visual debugger to the Excalidraw app (only).
1133 lines
35 KiB
TypeScript
1133 lines
35 KiB
TypeScript
import polyfill from "../packages/excalidraw/polyfill";
|
|
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 {
|
|
APP_NAME,
|
|
EVENT,
|
|
THEME,
|
|
TITLE_TIMEOUT,
|
|
VERSION_TIMEOUT,
|
|
} from "../packages/excalidraw/constants";
|
|
import { loadFromBlob } from "../packages/excalidraw/data/blob";
|
|
import type {
|
|
FileId,
|
|
NonDeletedExcalidrawElement,
|
|
OrderedExcalidrawElement,
|
|
} from "../packages/excalidraw/element/types";
|
|
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
|
|
import { t } from "../packages/excalidraw/i18n";
|
|
import {
|
|
Excalidraw,
|
|
LiveCollaborationTrigger,
|
|
TTDDialogTrigger,
|
|
StoreAction,
|
|
reconcileElements,
|
|
} from "../packages/excalidraw";
|
|
import type {
|
|
AppState,
|
|
ExcalidrawImperativeAPI,
|
|
BinaryFiles,
|
|
ExcalidrawInitialDataState,
|
|
UIAppState,
|
|
} from "../packages/excalidraw/types";
|
|
import type { ResolvablePromise } from "../packages/excalidraw/utils";
|
|
import {
|
|
debounce,
|
|
getVersion,
|
|
getFrame,
|
|
isTestEnv,
|
|
preventUnload,
|
|
resolvablePromise,
|
|
isRunningInIframe,
|
|
} from "../packages/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 "../packages/excalidraw/data/restore";
|
|
import { restore, restoreAppState } 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 {
|
|
LibraryIndexedDBAdapter,
|
|
LibraryLocalStorageMigrationAdapter,
|
|
LocalData,
|
|
} from "./data/LocalData";
|
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
|
import clsx from "clsx";
|
|
import {
|
|
parseLibraryTokensFromUrl,
|
|
useHandleLibrary,
|
|
} from "../packages/excalidraw/data/library";
|
|
import { AppMainMenu } from "./components/AppMainMenu";
|
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
|
import { AppFooter } from "./components/AppFooter";
|
|
import { Provider, useAtom, useAtomValue } from "jotai";
|
|
import { useAtomWithInitialValue } from "../packages/excalidraw/jotai";
|
|
import { appJotaiStore } from "./app-jotai";
|
|
|
|
import "./index.scss";
|
|
import type { 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";
|
|
import CollabError, { collabErrorIndicatorAtom } from "./collab/CollabError";
|
|
import type { RemoteExcalidrawElement } from "../packages/excalidraw/data/reconcile";
|
|
import {
|
|
CommandPalette,
|
|
DEFAULT_CATEGORIES,
|
|
} from "../packages/excalidraw/components/CommandPalette/CommandPalette";
|
|
import {
|
|
GithubIcon,
|
|
XBrandIcon,
|
|
DiscordIcon,
|
|
ExcalLogo,
|
|
usersIcon,
|
|
exportToPlus,
|
|
share,
|
|
youtubeIcon,
|
|
} from "../packages/excalidraw/components/icons";
|
|
import { appThemeAtom, 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";
|
|
|
|
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 [appTheme, setAppTheme] = useAtom(appThemeAtom);
|
|
const { editorTheme } = 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 }),
|
|
storeAction: StoreAction.CAPTURE,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
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,
|
|
storeAction: StoreAction.UPDATE,
|
|
});
|
|
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,
|
|
storeAction: StoreAction.UPDATE,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Render the debug scene if the debug canvas is available
|
|
if (debugCanvasRef.current && excalidrawAPI) {
|
|
debugRenderer(debugCanvasRef.current, appState, window.devicePixelRatio);
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}}
|
|
>
|
|
<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 = () => {
|
|
return (
|
|
<TopErrorBoundary>
|
|
<Provider unstable_createStore={() => appJotaiStore}>
|
|
<ExcalidrawWrapper />
|
|
</Provider>
|
|
</TopErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default ExcalidrawApp;
|