/** * 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 } from "idb-keyval"; import { clearAppStateForLocalStorage } from "../../src/appState"; import { clearElementsForLocalStorage } from "../../src/element"; import { ExcalidrawElement, FileId } from "../../src/element/types"; import { AppState, BinaryFileData, BinaryFiles } from "../../src/types"; import { debounce } from "../../src/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 }; }, }); }