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: ( {text}} 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(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; }>({ promise: null! }); if (!initialStatePromiseRef.current.promise) { initialStatePromiseRef.current.promise = resolvablePromise(); } useEffect(() => { trackEvent("load", "frame", getFrame()); setTimeout(() => { trackEvent("load", "version", getVersion()); }, VERSION_TIMEOUT); }, []); const [excalidrawAPI, excalidrawRefCallback] = useCallbackRefState(); 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 (
{statusMessage && (
{statusMessage}
)}
); }, [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, 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 | 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( () => (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(null); const previousFilesRef = useRef(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( null, ); const onExportToBackend = async ( exportedElements: readonly NonDeletedExcalidrawElement[], appState: Partial, 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 ( excalidrawAPI!.setToast({ message })} appState={appState} elements={elements} /> ); }; const isOffline = useAtomValue(isOfflineAtom); const [collabDialogShown, setCollabDialogShown] = useState(false); const onCollabDialogOpen = useCallback(() => setCollabDialogShown(true), []); if (isSelfEmbedding) { return (

I'm not a pretzel!

); } return (
( )} UIOptions={{ canvasActions: { toggleTheme: true, export: { onExportToBackend, renderCustomUI: excalidrawAPI ? () => { return ( { excalidrawAPI.updateScene({ appState: { openDialog: { name: "imageExport" } }, }); }} /> ); } : undefined, }, }, }} renderTopRightUI={(isMobile) => { if (isMobile || !collabAPI || isCollabDisabled) { return null; } return (
{collabError.message && } setShareDialogState({ isOpen: true, type: "share" }) } />
); }} > setIsStorageSettingsOpen(true)} /> { setSaveAsDialog({ isOpen: true }); }} > 将您当前的工作保存为一个新的画布,存入您所配置的存储中。 { 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"); } }} /> { if (excalidrawAPI) { try { await onExportToBackend( excalidrawAPI.getSceneElements(), excalidrawAPI.getAppState(), excalidrawAPI.getFiles(), ); } catch (error: any) { setErrorMessage(error.message); } } }} /> {latestShareableLink && ( setLatestShareableLink(null)} setErrorMessage={setErrorMessage} /> )} {excalidrawAPI && !isCollabDisabled && ( )} {isCollaborating && isOffline && (
{t("alerts.collabOfflineWarning")}
)} {isStorageSettingsOpen && ( setIsStorageSettingsOpen(false)} /> )} {createCanvasDialogState.isOpen && ( )} {renameCanvasDialogState.isOpen && ( )} {saveAsDialogState.isOpen && ( )} {errorMessage && ( setErrorMessage("")}> {errorMessage} )}
); }; const ExcalidrawApp = () => { return ( appJotaiStore as any}> ); }; export default ExcalidrawApp;