mirror of
https://github.com/Dvorinka/excalidraw-full.git
synced 2026-06-04 14:22:57 +00:00
1279 lines
39 KiB
TypeScript
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;
|