Files
Excalidraw/excalidraw/excalidraw-app/App.tsx
T
2025-07-05 23:22:48 +08:00

1279 lines
39 KiB
TypeScript

import polyfill from "../packages/excalidraw/polyfill";
import LanguageDetector from "i18next-browser-languagedetector";
import { useCallback, useEffect, useRef, useState, useMemo } 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,
EDITOR_LS_KEYS,
EVENT,
THEME,
TITLE_TIMEOUT,
VERSION_TIMEOUT,
} from "../packages/excalidraw/constants";
import { loadFromBlob } from "../packages/excalidraw/data/blob";
import {
ExcalidrawElement,
FileId,
NonDeletedExcalidrawElement,
Theme,
} from "../packages/excalidraw/element/types";
import { useCallbackRefState } from "../packages/excalidraw/hooks/useCallbackRefState";
import { t } from "../packages/excalidraw/i18n";
import {
Excalidraw,
defaultLang,
LiveCollaborationTrigger,
TTDDialog,
TTDDialogTrigger,
Sidebar,
DefaultSidebar,
} from "../packages/excalidraw/index";
import {
AppState,
ExcalidrawImperativeAPI,
BinaryFiles,
ExcalidrawInitialDataState,
UIAppState,
} from "../packages/excalidraw/types";
import {
debounce,
getVersion,
getFrame,
isTestEnv,
preventUnload,
ResolvablePromise,
resolvablePromise,
isRunningInIframe,
} from "../packages/excalidraw/utils";
import {
FIREBASE_STORAGE_PREFIXES,
STORAGE_KEYS,
SYNC_BROWSER_TABS_TIMEOUT,
CREATIONS_SIDEBAR_NAME,
} from "./app_constants";
import Collab, {
CollabAPI,
collabAPIAtom,
isCollaboratingAtom,
isOfflineAtom,
} from "./collab/Collab";
import {
exportToBackend,
getCollaborationLinkData,
isCollaborationLink,
loadScene,
} from "./data";
import {
importFromLocalStorage,
importUsernameFromLocalStorage,
} from "./data/localStorage";
import CustomStats from "./CustomStats";
import {
restore,
restoreAppState,
RestoredDataState,
} from "../packages/excalidraw/data/restore";
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 { reconcileElements } from "./collab/reconciliation";
import {
parseLibraryTokensFromUrl,
useHandleLibrary,
} from "../packages/excalidraw/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 "../packages/excalidraw/jotai";
import {
appJotaiStore,
storageConfigAtom,
userAtom,
currentCanvasIdAtom,
createCanvasDialogAtom,
renameCanvasDialogAtom,
saveAsDialogAtom,
} from "./app-jotai";
import { jwtDecode } from "jwt-decode";
import { useCanvasManagement } from "./hooks/useCanvasManagement";
import { useAuth } from "./hooks/useAuth";
import { CreateCanvasDialog } from "./components/CreateCanvasDialog";
import { RenameCanvasDialog } from "./components/RenameCanvasDialog";
import { SaveAsDialog } from "./components/SaveAsDialog";
import "./index.scss";
import { 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 StorageSettingsDialog from "./components/StorageSettingsDialog";
import { LoadIcon } from "../packages/excalidraw/components/icons";
import {
AuthError,
BackendStorageAdapter,
} from "./data/storageAdapters/BackendStorageAdapter";
import { IndexedDBStorageAdapter } from "./data/storageAdapters/IndexedDBStorageAdapter";
import { CloudflareKVAdapter } from "./data/storageAdapters/CloudflareKVAdapter";
import { S3StorageAdapter } from "./data/storageAdapters/S3StorageAdapter";
import { CanvasData, IStorageAdapter } from "./data/storage";
import { MyCreationsTab } from "./components/MyCreationsTab";
import { SaveAsImageUI } from "./components/SaveAsImageUI";
import { Action } from "../packages/excalidraw/actions/types";
import {
actionSaveFileToDisk,
actionSaveToActiveFile,
} from "../packages/excalidraw/actions";
import { generateMermaidCode } from "./data/ai";
import { EditorLocalStorage } from "../packages/excalidraw/data/EditorLocalStorage";
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 [user, setUser] = useAtom(userAtom);
const [storageConfig] = useAtom(storageConfigAtom);
const [isStorageSettingsOpen, setIsStorageSettingsOpen] = useState(false);
const isCollabDisabled = isRunningInIframe();
const [currentCanvasId, setCurrentCanvasId] = useAtom(currentCanvasIdAtom);
const [createCanvasDialogState] = useAtom(createCanvasDialogAtom);
const [renameCanvasDialogState] = useAtom(renameCanvasDialogAtom);
const [saveAsDialogState, setSaveAsDialog] = useAtom(saveAsDialogAtom);
const [saveStatus, setSaveStatus] = useState<
"saved" | "saving" | "unsaved" | "login-required"
>("saved");
const [lastSaveTime, setLastSaveTime] = useState<Date | null>(null);
const storageAdapter: IStorageAdapter = useMemo(() => {
if (storageConfig.type === "default" && user) {
return new BackendStorageAdapter();
}
if (
storageConfig.type === "kv" &&
storageConfig.kvUrl &&
storageConfig.kvApiToken
) {
return new CloudflareKVAdapter({
kv_url: storageConfig.kvUrl,
apiToken: storageConfig.kvApiToken,
});
}
if (
storageConfig.type === "s3" &&
storageConfig.s3AccessKeyId &&
storageConfig.s3SecretAccessKey &&
storageConfig.s3Region &&
storageConfig.s3BucketName
) {
return new S3StorageAdapter({
accessKeyId: storageConfig.s3AccessKeyId,
secretAccessKey: storageConfig.s3SecretAccessKey,
region: storageConfig.s3Region,
bucketName: storageConfig.s3BucketName,
});
}
return new IndexedDBStorageAdapter();
}, [storageConfig, user]);
const initialStatePromiseRef = useRef<{
promise: ResolvablePromise<ExcalidrawInitialDataState | null>;
}>({ promise: null! });
if (!initialStatePromiseRef.current.promise) {
initialStatePromiseRef.current.promise =
resolvablePromise<ExcalidrawInitialDataState | null>();
}
useEffect(() => {
trackEvent("load", "frame", getFrame());
setTimeout(() => {
trackEvent("load", "version", getVersion());
}, VERSION_TIMEOUT);
}, []);
const [excalidrawAPI, excalidrawRefCallback] =
useCallbackRefState<ExcalidrawImperativeAPI>();
const [, setShareDialogState] = useAtom(shareDialogStateAtom);
const [collabAPI] = useAtom(collabAPIAtom);
const [isCollaborating, setIsCollaborating] = useAtomWithInitialValue(
isCollaboratingAtom,
() => {
return isCollaborationLink(window.location.href);
},
);
const collabError = useAtomValue(collabErrorIndicatorAtom);
const resetSaveStatus = useCallback(() => {
setSaveStatus("saved");
setLastSaveTime(null);
}, []);
const {
canvases,
handleCanvasSelect,
handleCanvasDelete,
handleCanvasCreate,
handleCanvasRename,
handleCanvasSaveAs,
refreshCanvases,
} = useCanvasManagement({
storageAdapter,
excalidrawAPI,
user,
setErrorMessage,
resetSaveStatus,
});
const saveCanvas = useCallback(async () => {
if (!excalidrawAPI) {
return;
}
const { storageAdapter, currentCanvasId, refreshCanvases } =
onChangeRef.current;
if (currentCanvasId) {
setSaveStatus("saving");
try {
await storageAdapter.saveCanvas(currentCanvasId, {
elements: excalidrawAPI.getSceneElements(),
appState: excalidrawAPI.getAppState(),
files: excalidrawAPI.getFiles(),
});
setSaveStatus("saved");
setLastSaveTime(new Date());
await refreshCanvases();
} catch (e: any) {
if (e instanceof AuthError) {
setSaveStatus("login-required");
} else {
setSaveStatus("unsaved");
}
console.error(e);
}
}
}, [excalidrawAPI]);
const renderTopLeftUI = useCallback(
(isMobile: boolean) => {
if (isMobile) {
return null;
}
let statusMessage = "";
if (saveStatus === "saving") {
statusMessage = "正在保存...";
} else if (saveStatus === "saved") {
if (lastSaveTime) {
statusMessage = `已保存于 ${lastSaveTime.toLocaleTimeString()}`;
} else {
statusMessage = "已保存";
}
} else if (saveStatus === "unsaved") {
statusMessage = "存在未保存的更改";
} else if (saveStatus === "login-required") {
statusMessage = "您必须登录才能保存更改";
}
return (
<div style={{ display: "flex", alignItems: "center" }}>
<Sidebar.Trigger
name={CREATIONS_SIDEBAR_NAME}
icon={LoadIcon}
title="My Creations"
/>
{statusMessage && (
<div
style={{
marginLeft: "0.5rem",
color: "var(--color-gray-40)",
fontSize: "0.8em",
fontStyle: "italic",
}}
>
{statusMessage}
</div>
)}
</div>
);
},
[saveStatus, lastSaveTime],
);
useAuth(setUser);
useHandleLibrary({
excalidrawAPI,
adapter: LibraryIndexedDBAdapter,
migrationAdapter: LibraryLocalStorageMigrationAdapter,
});
useEffect(() => {
const searchParams = new URLSearchParams(window.location.search);
const token = searchParams.get("token");
if (token) {
localStorage.setItem("token", token);
window.history.replaceState({}, document.title, window.location.pathname);
}
const storedToken = localStorage.getItem("token");
if (storedToken) {
try {
const decodedToken: any = jwtDecode(storedToken);
if (decodedToken.exp * 1000 > Date.now()) {
setUser({
id: decodedToken.userId,
githubId: decodedToken.githubId,
login: decodedToken.login,
avatarUrl: decodedToken.avatarUrl,
name: decodedToken.name,
});
} else {
localStorage.removeItem("token");
}
} catch (error) {
console.error("Invalid token:", error);
localStorage.removeItem("token");
}
}
}, [setUser]);
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(),
});
});
}
LocalData.fileStorage.clearObsoleteFiles({ currentFileIds: fileIds });
}
}
};
const loadCanvas = async () => {
const jsonMatch = window.location.hash.match(
/^#json=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/,
);
const urlMatch = window.location.hash.match(/^#url=(.*)$/);
const isCollab =
isCollaborationLink(window.location.href) ||
isCollaborationLink(document.referrer);
if (isCollab || jsonMatch || urlMatch) {
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
} else {
let data: ResolutionType<typeof initializeScene> | null = null;
if (!currentCanvasId) {
try {
const newCanvas = await storageAdapter.createCanvas({
elements: [],
appState: excalidrawAPI.getAppState(),
files: {},
});
setCurrentCanvasId(newCanvas.id);
data = {
scene: {
elements: [],
appState: excalidrawAPI.getAppState(),
},
isExternalScene: false,
};
} catch (e) {
console.error(e);
setErrorMessage(
e instanceof Error ? e.message : "Failed to create a new canvas.",
);
return;
}
} else {
try {
if (currentCanvasId) {
const canvasData: CanvasData | null =
await storageAdapter.loadCanvas(currentCanvasId);
if (canvasData) {
data = {
scene: {
elements: canvasData.elements,
appState: restoreAppState(
canvasData.appState,
excalidrawAPI.getAppState(),
),
files: canvasData.files,
},
isExternalScene: false,
};
} else {
// Canvas not found, create a new one
setCurrentCanvasId(null); // Reset invalid id
// This will trigger a re-render and the logic will create a new canvas
return;
}
}
} catch (e) {
console.error("Failed to load canvas data.", e);
const resetConfirmed = await openConfirmModal({
title: "画布加载失败",
description:
"无法加载画布,它可能已损坏。您想重置并创建一个新的空白画布吗?",
actionLabel: "重置画布",
color: "danger",
});
if (resetConfirmed) {
setCurrentCanvasId(null);
// This will re-trigger the effect, and since currentCanvasId is null,
// it will enter the `if (!currentCanvasId)` block and create a new canvas.
// So we should just return.
return;
} else {
// User cancelled. The app is in a broken state.
// We can't load the canvas. We should probably show an error.
// A simple error message might be enough.
const errorMessage = "无法加载指定的画布。";
setErrorMessage(errorMessage);
initialStatePromiseRef.current.promise.resolve({
appState: { errorMessage },
});
return;
}
}
}
if (data) {
loadImages(data, true);
initialStatePromiseRef.current.promise.resolve(data.scene);
} else {
initializeScene({ collabAPI, excalidrawAPI }).then(async (data) => {
loadImages(data, true);
initialStatePromiseRef.current.promise.resolve(data.scene);
});
}
}
};
loadCanvas();
const onHashChange = async (event: HashChangeEvent) => {
event.preventDefault();
const libraryUrlTokens = parseLibraryTokensFromUrl();
if (!libraryUrlTokens) {
if (
collabAPI?.isCollaborating() &&
!isCollaborationLink(window.location.href)
) {
collabAPI.stopCollaboration(false);
}
excalidrawAPI.updateScene({ appState: { isLoading: true } });
initializeScene({ collabAPI, excalidrawAPI }).then((data) => {
loadImages(data);
if (data.scene) {
excalidrawAPI.updateScene({
...data.scene,
...restore(data.scene, null, null, { repairBindings: true }),
commitToHistory: true,
});
}
});
}
};
const titleTimeout = setTimeout(
() => (document.title = APP_NAME),
TITLE_TIMEOUT,
);
const syncData = debounce(() => {
if (isTestEnv()) {
return;
}
if (
!document.hidden &&
((collabAPI && !collabAPI.isCollaborating()) || isCollabDisabled)
) {
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,
});
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) &&
!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,
user,
storageAdapter,
currentCanvasId,
setCurrentCanvasId,
setErrorMessage,
resetSaveStatus,
]);
useEffect(() => {
if (!excalidrawAPI) {
return;
}
excalidrawAPI.unregisterAction(actionSaveFileToDisk);
excalidrawAPI.unregisterAction(actionSaveToActiveFile);
const newSaveAction = {
name: "saveFileToDisk",
trackEvent: { category: "canvas" },
perform: async () => {
console.log("Manual saving...");
await saveCanvas();
return {
commitToHistory: false,
};
},
keyTest: (event: KeyboardEvent) =>
!event.altKey &&
!event.shiftKey &&
event.key.toLowerCase() === "s" &&
(event.ctrlKey || event.metaKey),
} as Action;
excalidrawAPI.registerAction(newSaveAction);
return () => {
excalidrawAPI.unregisterAction(newSaveAction);
excalidrawAPI.registerAction(actionSaveFileToDisk);
excalidrawAPI.registerAction(actionSaveToActiveFile);
};
}, [excalidrawAPI, saveCanvas]);
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) ||
importFromLocalStorage().appState?.theme ||
THEME.LIGHT,
);
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LOCAL_STORAGE_THEME, theme);
document.documentElement.classList.toggle("dark", theme === THEME.DARK);
}, [theme]);
const onChangeRef = useRef({
storageAdapter,
currentCanvasId,
refreshCanvases,
collabAPI,
});
onChangeRef.current = {
storageAdapter,
currentCanvasId,
refreshCanvases,
collabAPI,
};
const previousElementsRef = useRef<readonly ExcalidrawElement[] | null>(null);
const previousFilesRef = useRef<BinaryFiles | null>(null);
const debouncedSave = useMemo(() => {
const save = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { storageAdapter, currentCanvasId } = onChangeRef.current;
if (currentCanvasId) {
console.log("Saving...");
setSaveStatus("saving");
try {
await storageAdapter.saveCanvas(currentCanvasId, {
elements,
appState,
files,
});
setSaveStatus("saved");
setLastSaveTime(new Date());
await onChangeRef.current.refreshCanvases();
} catch (e: any) {
if (e instanceof AuthError) {
setSaveStatus("login-required");
} else {
setSaveStatus("unsaved");
}
console.error(e);
}
}
};
return debounce(save, 5000);
}, []);
const onChange = (
elements: readonly ExcalidrawElement[],
appState: AppState,
files: BinaryFiles,
) => {
const { collabAPI, currentCanvasId } = onChangeRef.current;
setTheme(appState.theme);
const didElementsChange =
previousElementsRef.current !== elements ||
JSON.stringify(previousElementsRef.current) !== JSON.stringify(elements);
const didFilesChange =
previousFilesRef.current !== files ||
JSON.stringify(previousFilesRef.current) !== JSON.stringify(files);
if (collabAPI?.isCollaborating()) {
collabAPI.syncElements(elements);
} else if (currentCanvasId && (didElementsChange || didFilesChange)) {
setSaveStatus("unsaved");
debouncedSave(elements as NonDeletedExcalidrawElement[], appState, files);
}
// Update refs for the next comparison
previousElementsRef.current = elements;
previousFilesRef.current = files;
if (!LocalData.isSavePaused()) {
LocalData.save(elements, appState, files, () => {
if (excalidrawAPI) {
let didChange = false;
const elements = excalidrawAPI
.getSceneElementsIncludingDeleted()
.map((element) => {
if (
LocalData.fileStorage.shouldUpdateImageElementStatus(element)
) {
const newElement = newElementWith(element, { status: "saved" });
if (newElement !== element) {
didChange = true;
}
return newElement;
}
return element;
});
if (didChange) {
excalidrawAPI.updateScene({
elements,
});
}
}
});
}
};
const [latestShareableLink, setLatestShareableLink] = useState<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 [collabDialogShown, setCollabDialogShown] = useState(false);
const onCollabDialogOpen = useCallback(() => setCollabDialogShown(true), []);
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}
initialData={initialStatePromiseRef.current.promise}
onChange={onChange}
isCollaborating={isCollaborating}
onPointerUpdate={collabAPI?.onPointerUpdate}
langCode={langCode}
renderCustomStats={renderCustomStats}
detectScroll={false}
handleKeyboardGlobally={true}
theme={theme}
renderTopLeftUI={renderTopLeftUI}
renderLeftSidebar={() => (
<Sidebar name={CREATIONS_SIDEBAR_NAME} position="left" __fallback>
<MyCreationsTab
canvases={canvases}
onCanvasSelect={handleCanvasSelect}
onCanvasDelete={handleCanvasDelete}
currentCanvasId={currentCanvasId}
/>
</Sidebar>
)}
UIOptions={{
canvasActions: {
toggleTheme: true,
export: {
onExportToBackend,
renderCustomUI: excalidrawAPI
? () => {
return (
<SaveAsImageUI
onSuccess={() => {
excalidrawAPI.updateScene({
appState: { openDialog: { name: "imageExport" } },
});
}}
/>
);
}
: undefined,
},
},
}}
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>
);
}}
>
<DefaultSidebar __fallback />
<AppMainMenu
onCollabDialogOpen={onCollabDialogOpen}
isCollaborating={isCollaborating}
isCollabEnabled={!isCollabDisabled}
onStorageSettingsClick={() => setIsStorageSettingsOpen(true)}
/>
<AppWelcomeScreen
onCollabDialogOpen={onCollabDialogOpen}
isCollabEnabled={!isCollabDisabled}
/>
<OverwriteConfirmDialog>
<OverwriteConfirmDialog.Actions.ExportToImage />
<OverwriteConfirmDialog.Actions.SaveToDisk />
<OverwriteConfirmDialog.Action
title="另存为新画布"
actionLabel="另存为..."
onClick={() => {
setSaveAsDialog({ isOpen: true });
}}
>
将您当前的工作保存为一个新的画布,存入您所配置的存储中。
</OverwriteConfirmDialog.Action>
</OverwriteConfirmDialog>
<AppFooter />
<TTDDialog
onTextSubmit={async (input) => {
try {
const openAIKey =
EditorLocalStorage.get(EDITOR_LS_KEYS.OAI_API_KEY) || "123";
const openAIUrl =
EditorLocalStorage.get(EDITOR_LS_KEYS.OAI_BASE_URL) ||
"/api/v2";
const modelName =
EditorLocalStorage.get(EDITOR_LS_KEYS.OAI_MODEL_NAME) ||
"gpt-4.1-mini";
if (!openAIKey || !openAIUrl) {
throw new Error(
"OpenAI API key or URL are not configured in environment variables.",
);
}
const generatedResponse = await generateMermaidCode(
input,
openAIKey as string,
openAIUrl as string,
modelName as string,
);
return { generatedResponse: generatedResponse.code };
} catch (err: any) {
throw new Error("Request failed");
}
}}
/>
<TTDDialogTrigger />
<ShareDialog
collabAPI={collabAPI}
onExportToBackend={async () => {
if (excalidrawAPI) {
try {
await onExportToBackend(
excalidrawAPI.getSceneElements(),
excalidrawAPI.getAppState(),
excalidrawAPI.getFiles(),
);
} catch (error: any) {
setErrorMessage(error.message);
}
}
}}
/>
{latestShareableLink && (
<ShareableLinkDialog
link={latestShareableLink}
onCloseRequest={() => setLatestShareableLink(null)}
setErrorMessage={setErrorMessage}
/>
)}
{excalidrawAPI && !isCollabDisabled && (
<Collab excalidrawAPI={excalidrawAPI} />
)}
{isCollaborating && isOffline && (
<div className="collab-offline-warning">
{t("alerts.collabOfflineWarning")}
</div>
)}
{isStorageSettingsOpen && (
<StorageSettingsDialog
onClose={() => setIsStorageSettingsOpen(false)}
/>
)}
{createCanvasDialogState.isOpen && (
<CreateCanvasDialog onCanvasCreate={handleCanvasCreate} />
)}
{renameCanvasDialogState.isOpen && (
<RenameCanvasDialog onCanvasRename={handleCanvasRename} />
)}
{saveAsDialogState.isOpen && (
<SaveAsDialog onCanvasSaveAs={handleCanvasSaveAs} />
)}
{errorMessage && (
<ErrorDialog onClose={() => setErrorMessage("")}>
{errorMessage}
</ErrorDialog>
)}
</Excalidraw>
</div>
);
};
const ExcalidrawApp = () => {
return (
<TopErrorBoundary>
<Provider unstable_createStore={() => appJotaiStore as any}>
<ExcalidrawWrapper />
</Provider>
</TopErrorBoundary>
);
};
export default ExcalidrawApp;