diff --git a/excalidraw-app/App.tsx b/excalidraw-app/App.tsx index 932743ddfd..7b1f8be409 100644 --- a/excalidraw-app/App.tsx +++ b/excalidraw-app/App.tsx @@ -112,7 +112,9 @@ import { import { updateStaleImageStatuses } from "./data/FileManager"; import { importFromLocalStorage, + importFromIndexedDB, importUsernameFromLocalStorage, + migrateFromLocalStorageToIndexedDB, } from "./data/localStorage"; import { loadFilesFromFirebase } from "./data/firebase"; @@ -218,7 +220,15 @@ const initializeScene = async (opts: { ); const externalUrlMatch = window.location.hash.match(/^#url=(.*)$/); - const localDataState = importFromLocalStorage(); + // migrate from localStorage to IndexedDB if needed + await migrateFromLocalStorageToIndexedDB(); + + // try to load from IndexedDB first, fallback to localStorage + let localDataState = await importFromIndexedDB(); + if (!localDataState.elements.length && !localDataState.appState) { + // fallback to localStorage if IndexedDB is empty + localDataState = importFromLocalStorage(); + } let scene: RestoredDataState & { scrollToContent?: boolean; @@ -504,7 +514,7 @@ const ExcalidrawWrapper = () => { TITLE_TIMEOUT, ); - const syncData = debounce(() => { + const syncData = debounce(async () => { if (isTestEnv()) { return; } @@ -514,7 +524,12 @@ const ExcalidrawWrapper = () => { ) { // don't sync if local state is newer or identical to browser state if (isBrowserStorageStateNewer(STORAGE_KEYS.VERSION_DATA_STATE)) { - const localDataState = importFromLocalStorage(); + // try to load from IndexedDB first, fallback to localStorage + let localDataState = await importFromIndexedDB(); + if (!localDataState.elements.length && !localDataState.appState) { + // fallback to localStorage if IndexedDB is empty + localDataState = importFromLocalStorage(); + } const username = importUsernameFromLocalStorage(); setLangCode(getPreferredLanguage()); excalidrawAPI.updateScene({ diff --git a/excalidraw-app/CustomStats.tsx b/excalidraw-app/CustomStats.tsx index 543af5cfde..8273721b1a 100644 --- a/excalidraw-app/CustomStats.tsx +++ b/excalidraw-app/CustomStats.tsx @@ -21,11 +21,23 @@ type StorageSizes = { scene: number; total: number }; const STORAGE_SIZE_TIMEOUT = 500; -const getStorageSizes = debounce((cb: (sizes: StorageSizes) => void) => { - cb({ - scene: getElementsStorageSize(), - total: getTotalStorageSize(), - }); +const getStorageSizes = debounce(async (cb: (sizes: StorageSizes) => void) => { + try { + const [scene, total] = await Promise.all([ + getElementsStorageSize(), + getTotalStorageSize(), + ]); + cb({ + scene, + total, + }); + } catch (error) { + console.error("Failed to get storage sizes:", error); + cb({ + scene: 0, + total: 0, + }); + } }, STORAGE_SIZE_TIMEOUT); type Props = { diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts index 9ad6dc9256..405e04582f 100644 --- a/excalidraw-app/data/LocalData.ts +++ b/excalidraw-app/data/LocalData.ts @@ -65,7 +65,7 @@ class LocalFileManager extends FileManager { }; } -const saveDataStateToLocalStorage = ( +const saveDataStateToIndexedDB = async ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { @@ -79,17 +79,15 @@ const saveDataStateToLocalStorage = ( _appState.openSidebar = null; } - localStorage.setItem( - STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, - JSON.stringify(clearElementsForLocalStorage(elements)), - ); - localStorage.setItem( - STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, - JSON.stringify(_appState), - ); + // save to IndexedDB + await Promise.all([ + ElementsIndexedDBAdapter.save(clearElementsForLocalStorage(elements)), + AppStateIndexedDBAdapter.save(_appState), + ]); + updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); } catch (error: any) { - // Unable to access window.localStorage + // unable to access IndexedDB console.error(error); } }; @@ -104,7 +102,7 @@ export class LocalData { files: BinaryFiles, onFilesSaved: () => void, ) => { - saveDataStateToLocalStorage(elements, appState); + await saveDataStateToIndexedDB(elements, appState); await this.fileStorage.saveFiles({ elements, @@ -256,3 +254,63 @@ export class LibraryLocalStorageMigrationAdapter { localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); } } + +/** IndexedDB Adapter for storing app state */ +export class AppStateIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = "excalidraw-app-state"; + /** app state data store key */ + private static key = "appStateData"; + + private static store = createStore( + `${AppStateIndexedDBAdapter.idb_name}-db`, + `${AppStateIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get>( + AppStateIndexedDBAdapter.key, + AppStateIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: Partial): MaybePromise { + return set( + AppStateIndexedDBAdapter.key, + data, + AppStateIndexedDBAdapter.store, + ); + } +} + +/** IndexedDB Adapter for storing elements */ +export class ElementsIndexedDBAdapter { + /** IndexedDB database and store name */ + private static idb_name = "excalidraw-elements"; + /** elements data store key */ + private static key = "elementsData"; + + private static store = createStore( + `${ElementsIndexedDBAdapter.idb_name}-db`, + `${ElementsIndexedDBAdapter.idb_name}-store`, + ); + + static async load() { + const IDBData = await get( + ElementsIndexedDBAdapter.key, + ElementsIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: ExcalidrawElement[]): MaybePromise { + return set( + ElementsIndexedDBAdapter.key, + data, + ElementsIndexedDBAdapter.store, + ); + } +} diff --git a/excalidraw-app/data/localStorage.ts b/excalidraw-app/data/localStorage.ts index bc0df4a678..62d3630a7a 100644 --- a/excalidraw-app/data/localStorage.ts +++ b/excalidraw-app/data/localStorage.ts @@ -9,6 +9,11 @@ import type { AppState } from "@excalidraw/excalidraw/types"; import { STORAGE_KEYS } from "../app_constants"; +import { + AppStateIndexedDBAdapter, + ElementsIndexedDBAdapter, +} from "./LocalData"; + export const saveUsernameToLocalStorage = (username: string) => { try { localStorage.setItem( @@ -74,28 +79,146 @@ export const importFromLocalStorage = () => { return { elements, appState }; }; -export const getElementsStorageSize = () => { +export const importFromIndexedDB = async () => { + let savedElements = null; + let savedState = null; + try { - const elements = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); - const elementsSize = elements?.length || 0; - return elementsSize; + savedElements = await ElementsIndexedDBAdapter.load(); + savedState = await AppStateIndexedDBAdapter.load(); } catch (error: any) { + // unable to access IndexedDB console.error(error); - return 0; + } + + let elements: ExcalidrawElement[] = []; + if (savedElements) { + try { + elements = clearElementsForLocalStorage(savedElements); + } catch (error: any) { + console.error(error); + } + } + + let appState = null; + if (savedState) { + try { + appState = { + ...getDefaultAppState(), + ...clearAppStateForLocalStorage(savedState), + }; + } catch (error: any) { + console.error(error); + } + } + return { elements, appState }; +}; + +export const migrateFromLocalStorageToIndexedDB = async () => { + try { + // check if we have data in localStorage + const savedElements = localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, + ); + const savedState = localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, + ); + + if (savedElements || savedState) { + // parse and migrate elements + if (savedElements) { + try { + const elements = JSON.parse(savedElements); + await ElementsIndexedDBAdapter.save(elements); + } catch (error) { + console.error("Failed to migrate elements:", error); + } + } + + // parse and migrate app state + if (savedState) { + try { + const appState = JSON.parse(savedState); + await AppStateIndexedDBAdapter.save(appState); + } catch (error) { + console.error("Failed to migrate app state:", error); + } + } + + // clear localStorage after successful migration + localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS); + localStorage.removeItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); + } + } catch (error) { + console.error("Migration failed:", error); } }; -export const getTotalStorageSize = () => { +/** + * Get the size of elements stored in IndexedDB (with localStorage fallback) + * @returns Promise - Size in bytes + */ +export const getElementsStorageSize = async () => { try { - const appState = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_APP_STATE); + const elements = await ElementsIndexedDBAdapter.load(); + if (elements) { + // calculate size by stringifying the data + const elementsString = JSON.stringify(elements); + return elementsString.length; + } + return 0; + } catch (error: any) { + console.error("Failed to get elements size from IndexedDB:", error); + // fallback to localStorage + try { + const elements = localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, + ); + return elements?.length || 0; + } catch (localStorageError: any) { + console.error( + "Failed to get elements size from localStorage:", + localStorageError, + ); + return 0; + } + } +}; + +/** + * Get the total size of all data stored in IndexedDB and localStorage + * @returns Promise - Size in bytes + */ +export const getTotalStorageSize = async () => { + try { + const appState = await AppStateIndexedDBAdapter.load(); const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); - const appStateSize = appState?.length || 0; + const appStateSize = appState ? JSON.stringify(appState).length : 0; const collabSize = collab?.length || 0; - return appStateSize + collabSize + getElementsStorageSize(); + const elementsSize = await getElementsStorageSize(); + return appStateSize + collabSize + elementsSize; } catch (error: any) { - console.error(error); - return 0; + console.error("Failed to get total storage size from IndexedDB:", error); + // fallback to localStorage + try { + const appState = localStorage.getItem( + STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, + ); + const collab = localStorage.getItem(STORAGE_KEYS.LOCAL_STORAGE_COLLAB); + + const appStateSize = appState?.length || 0; + const collabSize = collab?.length || 0; + + const elementsSize = await getElementsStorageSize(); + return appStateSize + collabSize + elementsSize; + } catch (localStorageError: any) { + console.error( + "Failed to get total storage size from localStorage:", + localStorageError, + ); + return 0; + } } };