/** * This file deals with saving data state (appState, elements, images, ...) * locally to the browser. * * Notes: * * - DataState refers to full state of the app: appState, elements, images, * though some state is saved separately (collab username, library) for one * reason or another. We also save different data to different storage * (localStorage, indexedDB). */ import { createStore, entries, del, getMany, set, setMany, get, } from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../packages/excalidraw/appState"; import type { LibraryPersistedData } from "../../packages/excalidraw/data/library"; import type { ImportedDataState } from "../../packages/excalidraw/data/types"; import { clearElementsForLocalStorage } from "../../packages/excalidraw/element"; import type { ExcalidrawElement, FileId, } from "../../packages/excalidraw/element/types"; import type { AppState, BinaryFileData, BinaryFiles, } from "../../packages/excalidraw/types"; import type { MaybePromise } from "../../packages/excalidraw/utility-types"; import { debounce } from "../../packages/excalidraw/utils"; import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants"; import { FileManager } from "./FileManager"; import { Locker } from "./Locker"; import { updateBrowserStateVersion } from "./tabSync"; const filesStore = createStore("files-db", "files-store"); class LocalFileManager extends FileManager { clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => { await entries(filesStore).then((entries) => { for (const [id, imageData] of entries as [FileId, BinaryFileData][]) { // if image is unused (not on canvas) & is older than 1 day, delete it // from storage. We check `lastRetrieved` we care about the last time // the image was used (loaded on canvas), not when it was initially // created. if ( (!imageData.lastRetrieved || Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) && !opts.currentFileIds.includes(id as FileId) ) { del(id, filesStore); } } }); }; } const saveDataStateToLocalStorage = ( elements: readonly ExcalidrawElement[], appState: AppState, ) => { try { localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS, JSON.stringify(clearElementsForLocalStorage(elements)), ); localStorage.setItem( STORAGE_KEYS.LOCAL_STORAGE_APP_STATE, JSON.stringify(clearAppStateForLocalStorage(appState)), ); updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE); } catch (error: any) { // Unable to access window.localStorage console.error(error); } }; type SavingLockTypes = "collaboration"; export class LocalData { private static _save = debounce( async ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, onFilesSaved: () => void, ) => { saveDataStateToLocalStorage(elements, appState); await this.fileStorage.saveFiles({ elements, files, }); onFilesSaved(); }, SAVE_TO_LOCAL_STORAGE_TIMEOUT, ); /** Saves DataState, including files. Bails if saving is paused */ static save = ( elements: readonly ExcalidrawElement[], appState: AppState, files: BinaryFiles, onFilesSaved: () => void, ) => { // we need to make the `isSavePaused` check synchronously (undebounced) if (!this.isSavePaused()) { this._save(elements, appState, files, onFilesSaved); } }; static flushSave = () => { this._save.flush(); }; private static locker = new Locker(); static pauseSave = (lockType: SavingLockTypes) => { this.locker.lock(lockType); }; static resumeSave = (lockType: SavingLockTypes) => { this.locker.unlock(lockType); }; static isSavePaused = () => { return document.hidden || this.locker.isLocked(); }; // --------------------------------------------------------------------------- static fileStorage = new LocalFileManager({ getFiles(ids) { return getMany(ids, filesStore).then( async (filesData: (BinaryFileData | undefined)[]) => { const loadedFiles: BinaryFileData[] = []; const erroredFiles = new Map(); const filesToSave: [FileId, BinaryFileData][] = []; filesData.forEach((data, index) => { const id = ids[index]; if (data) { const _data: BinaryFileData = { ...data, lastRetrieved: Date.now(), }; filesToSave.push([id, _data]); loadedFiles.push(_data); } else { erroredFiles.set(id, true); } }); try { // save loaded files back to storage with updated `lastRetrieved` setMany(filesToSave, filesStore); } catch (error) { console.warn(error); } return { loadedFiles, erroredFiles }; }, ); }, async saveFiles({ addedFiles }) { const savedFiles = new Map(); const erroredFiles = new Map(); // before we use `storage` event synchronization, let's update the flag // optimistically. Hopefully nothing fails, and an IDB read executed // before an IDB write finishes will read the latest value. updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES); await Promise.all( [...addedFiles].map(async ([id, fileData]) => { try { await set(id, fileData, filesStore); savedFiles.set(id, true); } catch (error: any) { console.error(error); erroredFiles.set(id, true); } }), ); return { savedFiles, erroredFiles }; }, }); } export class LibraryIndexedDBAdapter { /** IndexedDB database and store name */ private static idb_name = STORAGE_KEYS.IDB_LIBRARY; /** library data store key */ private static key = "libraryData"; private static store = createStore( `${LibraryIndexedDBAdapter.idb_name}-db`, `${LibraryIndexedDBAdapter.idb_name}-store`, ); static async load() { const IDBData = await get( LibraryIndexedDBAdapter.key, LibraryIndexedDBAdapter.store, ); return IDBData || null; } static save(data: LibraryPersistedData): MaybePromise { return set( LibraryIndexedDBAdapter.key, data, LibraryIndexedDBAdapter.store, ); } } /** LS Adapter used only for migrating LS library data * to indexedDB */ export class LibraryLocalStorageMigrationAdapter { static load() { const LSData = localStorage.getItem( STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY, ); if (LSData != null) { const libraryItems: ImportedDataState["libraryItems"] = JSON.parse(LSData); if (libraryItems) { return { libraryItems }; } } return null; } static clear() { localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY); } }