diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | bfc2cec7d43eb8eaa46dd3f91084932381257059 (patch) | |
| tree | 0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/data/LocalData.ts | |
| parent | 225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff) | |
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/data/LocalData.ts')
| -rw-r--r-- | excalidraw-app/data/LocalData.ts | 258 |
1 files changed, 258 insertions, 0 deletions
diff --git a/excalidraw-app/data/LocalData.ts b/excalidraw-app/data/LocalData.ts new file mode 100644 index 0000000..3ab0ad9 --- /dev/null +++ b/excalidraw-app/data/LocalData.ts @@ -0,0 +1,258 @@ +/** + * 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 "@excalidraw/excalidraw/appState"; +import { + CANVAS_SEARCH_TAB, + DEFAULT_SIDEBAR, +} from "@excalidraw/excalidraw/constants"; +import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library"; +import type { ImportedDataState } from "@excalidraw/excalidraw/data/types"; +import { clearElementsForLocalStorage } from "@excalidraw/excalidraw/element"; +import type { + ExcalidrawElement, + FileId, +} from "@excalidraw/excalidraw/element/types"; +import type { + AppState, + BinaryFileData, + BinaryFiles, +} from "@excalidraw/excalidraw/types"; +import type { MaybePromise } from "@excalidraw/excalidraw/utility-types"; +import { debounce } from "@excalidraw/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 { + const _appState = clearAppStateForLocalStorage(appState); + + if ( + _appState.openSidebar?.name === DEFAULT_SIDEBAR.name && + _appState.openSidebar.tab === CANVAS_SEARCH_TAB + ) { + _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), + ); + 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<SavingLockTypes>(); + + 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<FileId, true>(); + + 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<FileId, BinaryFileData>(); + const erroredFiles = new Map<FileId, BinaryFileData>(); + + // 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, fileData); + } catch (error: any) { + console.error(error); + erroredFiles.set(id, fileData); + } + }), + ); + + 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<LibraryPersistedData>( + LibraryIndexedDBAdapter.key, + LibraryIndexedDBAdapter.store, + ); + + return IDBData || null; + } + + static save(data: LibraryPersistedData): MaybePromise<void> { + 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); + } +} |
