
Introducing independent change detection for appState and elements Generalizing object change, cleanup, refactoring, comments, solving typing issues Shaping increment, change, delta hierarchy Structural clone of elements Introducing store and incremental API Disabling buttons for canvas actions, smaller store and changes improvements Update history entry based on latest changes, iterate through the stack for visible changes to limit empty commands Solving concurrency issues, solving (partly) linear element issues, introducing commitToStore breaking change Fixing existing tests, updating snapshots Trying to be smarter on the appstate change detection Extending collab test, refactoring action / updateScene params, bugfixes Resetting snapshots Resetting snapshots UI / API tests for history - WIP Changing actions related to the observed appstate to at least update the store snapshot - WIP Adding skipping of snapshot update flag for most no-breaking changes compatible solution Ignoring uncomitted elements from local async actions, updating store directly in updateScene Bound element issues - WIP
813 lines
24 KiB
TypeScript
813 lines
24 KiB
TypeScript
import polyfill from "../src/polyfill";
|
|
import LanguageDetector from "i18next-browser-languagedetector";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { trackEvent } from "../src/analytics";
|
|
import { getDefaultAppState } from "../src/appState";
|
|
import { ErrorDialog } from "../src/components/ErrorDialog";
|
|
import { TopErrorBoundary } from "../src/components/TopErrorBoundary";
|
|
import {
|
|
APP_NAME,
|
|
EVENT,
|
|
THEME,
|
|
TITLE_TIMEOUT,
|
|
VERSION_TIMEOUT,
|
|
} from "../src/constants";
|
|
import { loadFromBlob } from "../src/data/blob";
|
|
import {
|
|
ExcalidrawElement,
|
|
FileId,
|
|
NonDeletedExcalidrawElement,
|
|
Theme,
|
|
} from "../src/element/types";
|
|
import { useCallbackRefState } from "../src/hooks/useCallbackRefState";
|
|
import { t } from "../src/i18n";
|
|
import {
|
|
Excalidraw,
|
|
defaultLang,
|
|
LiveCollaborationTrigger,
|
|
} from "../src/packages/excalidraw/index";
|
|
import {
|
|
AppState,
|
|
LibraryItems,
|
|
ExcalidrawImperativeAPI,
|
|
BinaryFiles,
|
|
ExcalidrawInitialDataState,
|
|
UIAppState,
|
|
} from "../src/types";
|
|
import {
|
|
debounce,
|
|
getVersion,
|
|
getFrame,
|
|
isTestEnv,
|
|
preventUnload,
|
|
ResolvablePromise,
|
|
resolvablePromise,
|
|
isRunningInIframe,
|
|
} from "../src/utils";
|
|
import {
|
|
FIREBASE_STORAGE_PREFIXES,
|
|
STORAGE_KEYS,
|
|
SYNC_BROWSER_TABS_TIMEOUT,
|
|
} from "./app_constants";
|
|
import Collab, {
|
|
CollabAPI,
|
|
collabAPIAtom,
|
|
collabDialogShownAtom,
|
|
isCollaboratingAtom,
|
|
isOfflineAtom,
|
|
} from "./collab/Collab";
|
|
import {
|
|
exportToBackend,
|
|
getCollaborationLinkData,
|
|
isCollaborationLink,
|
|
loadScene,
|
|
} from "./data";
|
|
import {
|
|
getLibraryItemsFromStorage,
|
|
importFromLocalStorage,
|
|
importUsernameFromLocalStorage,
|
|
} from "./data/localStorage";
|
|
import CustomStats from "./CustomStats";
|
|
import {
|
|
restore,
|
|
restoreAppState,
|
|
RestoredDataState,
|
|
} from "../src/data/restore";
|
|
import {
|
|
ExportToExcalidrawPlus,
|
|
exportToExcalidrawPlus,
|
|
} from "./components/ExportToExcalidrawPlus";
|
|
import { updateStaleImageStatuses } from "./data/FileManager";
|
|
import { newElementWith } from "../src/element/mutateElement";
|
|
import { isInitializedImageElement } from "../src/element/typeChecks";
|
|
import { loadFilesFromFirebase } from "./data/firebase";
|
|
import { LocalData } from "./data/LocalData";
|
|
import { isBrowserStorageStateNewer } from "./data/tabSync";
|
|
import clsx from "clsx";
|
|
import { reconcileElements } from "./collab/reconciliation";
|
|
import {
|
|
parseLibraryTokensFromUrl,
|
|
useHandleLibrary,
|
|
} from "../src/data/library";
|
|
import { AppMainMenu } from "./components/AppMainMenu";
|
|
import { AppWelcomeScreen } from "./components/AppWelcomeScreen";
|
|
import { AppFooter } from "./components/AppFooter";
|
|
import { atom, Provider, useAtom, useAtomValue } from "jotai";
|
|
import { useAtomWithInitialValue } from "../src/jotai";
|
|
import { appJotaiStore } from "./app-jotai";
|
|
|
|
import "./index.scss";
|
|
import { ResolutionType } from "../src/utility-types";
|
|
import { ShareableLinkDialog } from "../src/components/ShareableLinkDialog";
|
|
import { openConfirmModal } from "../src/components/OverwriteConfirm/OverwriteConfirmState";
|
|
import { OverwriteConfirmDialog } from "../src/components/OverwriteConfirm/OverwriteConfirm";
|
|
import Trans from "../src/components/Trans";
|
|
|
|
polyfill();
|
|
|
|
window.EXCALIDRAW_THROTTLE_RENDER = true;
|
|
|
|
let isSelfEmbedding = false;
|
|
|
|
if (window.self !== window.top) {
|
|
try {
|
|
const parentUrl = new URL(document.referrer);
|
|
const currentUrl = new URL(window.location.href);
|
|
if (parentUrl.origin === currentUrl.origin) {
|
|
isSelfEmbedding = true;
|
|
}
|
|
} catch (error) {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const languageDetector = new LanguageDetector();
|
|
languageDetector.init({
|
|
languageUtils: {},
|
|
});
|
|
|
|
const shareableLinkConfirmDialog = {
|
|
title: t("overwriteConfirm.modal.shareableLink.title"),
|
|
description: (
|
|
<Trans
|
|
i18nKey="overwriteConfirm.modal.shareableLink.description"
|
|
bold={(text) => <strong>{text}</strong>}
|
|
br={() => <br />}
|
|
/>
|
|
),
|
|
actionLabel: t("overwriteConfirm.modal.shareableLink.button"),
|
|
color: "danger",
|
|
} as const;
|
|
|
|
const initializeScene = async (opts: {
|
|
collabAPI: CollabAPI | null;
|
|
excalidrawAPI: ExcalidrawImperativeAPI;
|
|
}): Promise<
|
|
{ scene: ExcalidrawInitialDataState | null } & (
|
|
| { isExternalScene: true; id: string; key: string }
|
|
| { isExternalScene: false; id?: null; key?: null }
|
|
)
|
|
> => {
|
|
const searchParams = new URLSearchParams(window.location.search);
|
|
const id = searchParams.get("id");
|
|
const jsonBackendMatch = window.location.hash.match(
|
|
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
|
|
);
|
|
const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/);
|
|
|
|
const localDataState = importFromLocalStorage();
|
|
|
|
let scene: RestoredDataState & {
|
|
scrollToContent?: boolean;
|
|
} = await loadScene(null, null, localDataState);
|
|
|
|
let roomLinkData = getCollaborationLinkData(window.location.href);
|
|
const isExternalScene = !!(id || jsonBackendMatch || roomLinkData);
|
|
if (isExternalScene) {
|
|
if (
|
|
// don't prompt if scene is empty
|
|
!scene.elements.length ||
|
|
// don't prompt for collab scenes because we don't override local storage
|
|
roomLinkData ||
|
|
// otherwise, prompt whether user wants to override current scene
|
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
|
) {
|
|
if (jsonBackendMatch) {
|
|
scene = await loadScene(
|
|
jsonBackendMatch[1],
|
|
jsonBackendMatch[2],
|
|
localDataState,
|
|
);
|
|
}
|
|
scene.scrollToContent = true;
|
|
if (!roomLinkData) {
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
}
|
|
} else {
|
|
// https://github.com/excalidraw/excalidraw/issues/1919
|
|
if (document.hidden) {
|
|
return new Promise((resolve, reject) => {
|
|
window.addEventListener(
|
|
"focus",
|
|
() => initializeScene(opts).then(resolve).catch(reject),
|
|
{
|
|
once: true,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
roomLinkData = null;
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
}
|
|
} else if (externalUrlMatch) {
|
|
window.history.replaceState({}, APP_NAME, window.location.origin);
|
|
|
|
const url = externalUrlMatch[1];
|
|
try {
|
|
const request = await fetch(window.decodeURIComponent(url));
|
|
const data = await loadFromBlob(await request.blob(), null, null);
|
|
if (
|
|
!scene.elements.length ||
|
|
(await openConfirmModal(shareableLinkConfirmDialog))
|
|
) {
|
|
return { scene: data, isExternalScene };
|
|
}
|
|
} catch (error: any) {
|
|
return {
|
|
scene: {
|
|
appState: {
|
|
errorMessage: t("alerts.invalidSceneUrl"),
|
|
},
|
|
},
|
|
isExternalScene,
|
|
};
|
|
}
|
|
}
|
|
|
|
if (roomLinkData && opts.collabAPI) {
|
|
const { excalidrawAPI } = opts;
|
|
|
|
const scene = await opts.collabAPI.startCollaboration(roomLinkData);
|
|
|
|
return {
|
|
// when collaborating, the state may have already been updated at this
|
|
// point (we may have received updates from other clients), so reconcile
|
|
// elements and appState with existing state
|
|
scene: {
|
|
...scene,
|
|
appState: {
|
|
...restoreAppState(
|
|
{
|
|
...scene?.appState,
|
|
theme: localDataState?.appState?.theme || scene?.appState?.theme,
|
|
},
|
|
excalidrawAPI.getAppState(),
|
|
),
|
|
// necessary if we're invoking from a hashchange handler which doesn't
|
|
// go through App.initializeScene() that resets this flag
|
|
isLoading: false,
|
|
},
|
|
elements: reconcileElements(
|
|
scene?.elements || [],
|
|
excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
excalidrawAPI.getAppState(),
|
|
),
|
|
},
|
|
isExternalScene: true,
|
|
id: roomLinkData.roomId,
|
|
key: roomLinkData.roomKey,
|
|
};
|
|
} else if (scene) {
|
|
return isExternalScene && jsonBackendMatch
|
|
? {
|
|
scene,
|
|
isExternalScene,
|
|
id: jsonBackendMatch[1],
|
|
key: jsonBackendMatch[2],
|
|
}
|
|
: { scene, isExternalScene: false };
|
|
}
|
|
return { scene: null, isExternalScene: false };
|
|
};
|
|
|
|
const detectedLangCode = languageDetector.detect() || defaultLang.code;
|
|
export const appLangCodeAtom = atom(
|
|
Array.isArray(detectedLangCode) ? detectedLangCode[0] : detectedLangCode,
|
|
);
|
|
|
|
const ExcalidrawWrapper = () => {
|
|
const [errorMessage, setErrorMessage] = useState("");
|
|
const [langCode, setLangCode] = useAtom(appLangCodeAtom);
|
|
const isCollabDisabled = isRunningInIframe();
|
|
|
|
// initial state
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const initialStatePromiseRef = useRef<{
|
|
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
|
|
}>({ promise: null! });
|
|
if (!initialStatePromiseRef.current.promise) {
|
|
initialStatePromiseRef.current.promise =
|
|
resolvablePromise<ExcalidrawInitialDataState | null>();
|
|
}
|
|
|
|
useEffect(() => {
|
|
trackEvent("load", "frame", getFrame());
|
|
// Delayed so that the app has a time to load the latest SW
|
|
setTimeout(() => {
|
|
trackEvent("load", "version", getVersion());
|
|
}, VERSION_TIMEOUT);
|
|
}, []);
|
|
|
|
const [excalidrawAPI, excalidrawRefCallback] =
|
|
useCallbackRefState<ExcalidrawImperativeAPI>();
|
|
|
|
const [collabAPI] = useAtom(collabAPIAtom);
|
|
const [, setCollabDialogShown] = useAtom(collabDialogShownAtom);
|
|
const [isCollaborating] = useAtomWithInitialValue(isCollaboratingAtom, () => {
|
|
return isCollaborationLink(window.location.href);
|
|
});
|
|
|
|
useHandleLibrary({
|
|
excalidrawAPI,
|
|
getInitialLibraryItems: getLibraryItemsFromStorage,
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!excalidrawAPI || (!isCollabDisabled && !collabAPI)) {
|
|
return;
|
|
}
|
|
|
|
const loadImages = (
|
|
data: ResolutionType<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 }),
|
|
commitToStore: true,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const titleTimeout = setTimeout(
|
|
() => (document.title = APP_NAME),
|
|
TITLE_TIMEOUT,
|
|
);
|
|
|
|
const syncData = debounce(() => {
|
|
if (isTestEnv()) {
|
|
return;
|
|
}
|
|
if (
|
|
!document.hidden &&
|
|
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
|
|
) {
|
|
// don't sync if local state is newer or identical to browser state
|
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) {
|
|
const localDataState = importFromLocalStorage();
|
|
const username = importUsernameFromLocalStorage();
|
|
let langCode = languageDetector.detect() || defaultLang.code;
|
|
if (Array.isArray(langCode)) {
|
|
langCode = langCode[0];
|
|
}
|
|
setLangCode(langCode);
|
|
excalidrawAPI.updateScene({
|
|
...localDataState,
|
|
});
|
|
excalidrawAPI.updateLibrary({
|
|
libraryItems: getLibraryItemsFromStorage(),
|
|
});
|
|
collabAPI?.setUsername(username || "");
|
|
}
|
|
|
|
if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_FILES)) {
|
|
const elements = excalidrawAPI.getSceneElementsIncludingDeleted();
|
|
const currFiles = excalidrawAPI.getFiles();
|
|
const fileIds =
|
|
elements?.reduce((acc, element) => {
|
|
if (
|
|
isInitializedImageElement(element) &&
|
|
// only load and update images that aren't already loaded
|
|
!currFiles[element.fileId]
|
|
) {
|
|
return acc.concat(element.fileId);
|
|
}
|
|
return acc;
|
|
}, [] as FileId[]) || [];
|
|
if (fileIds.length) {
|
|
LocalData.fileStorage
|
|
.getFiles(fileIds)
|
|
.then(({ loadedFiles, erroredFiles }) => {
|
|
if (loadedFiles.length) {
|
|
excalidrawAPI.addFiles(loadedFiles);
|
|
}
|
|
updateStaleImageStatuses({
|
|
excalidrawAPI,
|
|
erroredFiles,
|
|
elements: excalidrawAPI.getSceneElementsIncludingDeleted(),
|
|
});
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}, SYNC_BROWSER_TABS_TIMEOUT);
|
|
|
|
const onUnload = () => {
|
|
LocalData.flushSave();
|
|
};
|
|
|
|
const visibilityChange = (event: FocusEvent | Event) => {
|
|
if (event.type === EVENT.BLUR || document.hidden) {
|
|
LocalData.flushSave();
|
|
}
|
|
if (
|
|
event.type === EVENT.VISIBILITY_CHANGE ||
|
|
event.type === EVENT.FOCUS
|
|
) {
|
|
syncData();
|
|
}
|
|
};
|
|
|
|
window.addEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
|
window.addEventListener(EVENT.UNLOAD, onUnload, false);
|
|
window.addEventListener(EVENT.BLUR, visibilityChange, false);
|
|
document.addEventListener(EVENT.VISIBILITY_CHANGE, visibilityChange, false);
|
|
window.addEventListener(EVENT.FOCUS, visibilityChange, false);
|
|
return () => {
|
|
window.removeEventListener(EVENT.HASHCHANGE, onHashChange, false);
|
|
window.removeEventListener(EVENT.UNLOAD, onUnload, false);
|
|
window.removeEventListener(EVENT.BLUR, visibilityChange, false);
|
|
window.removeEventListener(EVENT.FOCUS, visibilityChange, false);
|
|
document.removeEventListener(
|
|
EVENT.VISIBILITY_CHANGE,
|
|
visibilityChange,
|
|
false,
|
|
);
|
|
clearTimeout(titleTimeout);
|
|
};
|
|
}, [isCollabDisabled, collabAPI, excalidrawAPI, setLangCode]);
|
|
|
|
useEffect(() => {
|
|
const unloadHandler = (event: BeforeUnloadEvent) => {
|
|
LocalData.flushSave();
|
|
|
|
if (
|
|
excalidrawAPI &&
|
|
LocalData.fileStorage.shouldPreventUnload(
|
|
excalidrawAPI.getSceneElements(),
|
|
)
|
|
) {
|
|
preventUnload(event);
|
|
}
|
|
};
|
|
window.addEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
|
return () => {
|
|
window.removeEventListener(EVENT.BEFORE_UNLOAD, unloadHandler);
|
|
};
|
|
}, [excalidrawAPI]);
|
|
|
|
useEffect(() => {
|
|
languageDetector.cacheUserLanguage(langCode);
|
|
}, [langCode]);
|
|
|
|
const [theme, setTheme] = useState<Theme>(
|
|
() =>
|
|
(localStorage.getItem(
|
|
STORAGE_KEYS.LOCAL_STORAGE_THEME,
|
|
) as Theme | null) ||
|
|
// FIXME migration from old LS scheme. Can be removed later. #5660
|
|
importFromLocalStorage().appState?.theme ||
|
|
THEME.LIGHT,
|
|
);
|
|
|
|
useEffect(() => {
|
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
|
|
// currently only used for body styling during init (see public/index.html),
|
|
// but may change in the future
|
|
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
|
|
}, [theme]);
|
|
|
|
const onChange = (
|
|
elements: readonly ExcalidrawElement[],
|
|
appState: AppState,
|
|
files: BinaryFiles,
|
|
) => {
|
|
if (collabAPI?.isCollaborating()) {
|
|
collabAPI.syncElements(elements);
|
|
}
|
|
|
|
setTheme(appState.theme);
|
|
|
|
// this check is redundant, but since this is a hot path, it's best
|
|
// not to evaludate the nested expression every time
|
|
if (!LocalData.isSavePaused()) {
|
|
LocalData.save(elements, appState, files, () => {
|
|
if (excalidrawAPI) {
|
|
let didChange = false;
|
|
|
|
const elements = excalidrawAPI
|
|
.getSceneElementsIncludingDeleted()
|
|
.map((element) => {
|
|
if (
|
|
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
|
|
) {
|
|
const newElement = newElementWith(element, { status: "saved" });
|
|
if (newElement !== element) {
|
|
didChange = true;
|
|
}
|
|
return newElement;
|
|
}
|
|
return element;
|
|
});
|
|
|
|
if (didChange) {
|
|
excalidrawAPI.updateScene({
|
|
elements,
|
|
skipSnapshotUpdate: true,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
const [latestShareableLink, setLatestShareableLink] = useState<string | null>(
|
|
null,
|
|
);
|
|
|
|
const onExportToBackend = async (
|
|
exportedElements: readonly NonDeletedExcalidrawElement[],
|
|
appState: Partial<AppState>,
|
|
files: BinaryFiles,
|
|
canvas: HTMLCanvasElement,
|
|
) => {
|
|
if (exportedElements.length === 0) {
|
|
throw new Error(t("alerts.cannotExportEmptyCanvas"));
|
|
}
|
|
if (canvas) {
|
|
try {
|
|
const { url, errorMessage } = await exportToBackend(
|
|
exportedElements,
|
|
{
|
|
...appState,
|
|
viewBackgroundColor: appState.exportBackground
|
|
? appState.viewBackgroundColor
|
|
: getDefaultAppState().viewBackgroundColor,
|
|
},
|
|
files,
|
|
);
|
|
|
|
if (errorMessage) {
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
if (url) {
|
|
setLatestShareableLink(url);
|
|
}
|
|
} catch (error: any) {
|
|
if (error.name !== "AbortError") {
|
|
const { width, height } = canvas;
|
|
console.error(error, { width, height });
|
|
throw new Error(error.message);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
const renderCustomStats = (
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
|
appState: UIAppState,
|
|
) => {
|
|
return (
|
|
<CustomStats
|
|
setToast={(message) => excalidrawAPI!.setToast({ message })}
|
|
appState={appState}
|
|
elements={elements}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const onLibraryChange = async (items: LibraryItems) => {
|
|
if (!items.length) {
|
|
localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY);
|
|
return;
|
|
}
|
|
const serializedItems = JSON.stringify(items);
|
|
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_LIBRARY, serializedItems);
|
|
};
|
|
|
|
const isOffline = useAtomValue(isOfflineAtom);
|
|
|
|
// browsers generally prevent infinite self-embedding, there are
|
|
// cases where it still happens, and while we disallow self-embedding
|
|
// by not whitelisting our own origin, this serves as an additional guard
|
|
if (isSelfEmbedding) {
|
|
return (
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
textAlign: "center",
|
|
height: "100%",
|
|
}}
|
|
>
|
|
<h1>I'm not a pretzel!</h1>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
style={{ height: "100%" }}
|
|
className={clsx("excalidraw-app", {
|
|
"is-collaborating": isCollaborating,
|
|
})}
|
|
>
|
|
<Excalidraw
|
|
excalidrawAPI={excalidrawRefCallback}
|
|
onChange={onChange}
|
|
initialData={initialStatePromiseRef.current.promise}
|
|
isCollaborating={isCollaborating}
|
|
onPointerUpdate={collabAPI?.onPointerUpdate}
|
|
UIOptions={{
|
|
canvasActions: {
|
|
toggleTheme: true,
|
|
export: {
|
|
onExportToBackend,
|
|
renderCustomUI: (elements, appState, files) => {
|
|
return (
|
|
<ExportToExcalidrawPlus
|
|
elements={elements}
|
|
appState={appState}
|
|
files={files}
|
|
onError={(error) => {
|
|
excalidrawAPI?.updateScene({
|
|
appState: {
|
|
errorMessage: error.message,
|
|
},
|
|
});
|
|
}}
|
|
onSuccess={() => {
|
|
excalidrawAPI?.updateScene({
|
|
appState: { openDialog: null },
|
|
});
|
|
}}
|
|
/>
|
|
);
|
|
},
|
|
},
|
|
},
|
|
}}
|
|
langCode={langCode}
|
|
renderCustomStats={renderCustomStats}
|
|
detectScroll={false}
|
|
handleKeyboardGlobally={true}
|
|
onLibraryChange={onLibraryChange}
|
|
autoFocus={true}
|
|
theme={theme}
|
|
renderTopRightUI={(isMobile) => {
|
|
if (isMobile || !collabAPI || isCollabDisabled) {
|
|
return null;
|
|
}
|
|
return (
|
|
<LiveCollaborationTrigger
|
|
isCollaborating={isCollaborating}
|
|
onSelect={() => setCollabDialogShown(true)}
|
|
/>
|
|
);
|
|
}}
|
|
>
|
|
<AppMainMenu
|
|
setCollabDialogShown={setCollabDialogShown}
|
|
isCollaborating={isCollaborating}
|
|
isCollabEnabled={!isCollabDisabled}
|
|
/>
|
|
<AppWelcomeScreen
|
|
setCollabDialogShown={setCollabDialogShown}
|
|
isCollabEnabled={!isCollabDisabled}
|
|
/>
|
|
<OverwriteConfirmDialog>
|
|
<OverwriteConfirmDialog.Actions.ExportToImage />
|
|
<OverwriteConfirmDialog.Actions.SaveToDisk />
|
|
{excalidrawAPI && (
|
|
<OverwriteConfirmDialog.Action
|
|
title={t("overwriteConfirm.action.excalidrawPlus.title")}
|
|
actionLabel={t("overwriteConfirm.action.excalidrawPlus.button")}
|
|
onClick={() => {
|
|
exportToExcalidrawPlus(
|
|
excalidrawAPI.getSceneElements(),
|
|
excalidrawAPI.getAppState(),
|
|
excalidrawAPI.getFiles(),
|
|
);
|
|
}}
|
|
>
|
|
{t("overwriteConfirm.action.excalidrawPlus.description")}
|
|
</OverwriteConfirmDialog.Action>
|
|
)}
|
|
</OverwriteConfirmDialog>
|
|
<AppFooter />
|
|
{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} />
|
|
)}
|
|
{errorMessage && (
|
|
<ErrorDialog onClose={() => setErrorMessage("")}>
|
|
{errorMessage}
|
|
</ErrorDialog>
|
|
)}
|
|
</Excalidraw>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ExcalidrawApp = () => {
|
|
return (
|
|
<TopErrorBoundary>
|
|
<Provider unstable_createStore={() => appJotaiStore}>
|
|
<ExcalidrawWrapper />
|
|
</Provider>
|
|
</TopErrorBoundary>
|
|
);
|
|
};
|
|
|
|
export default ExcalidrawApp;
|