summaryrefslogtreecommitdiffstats
path: root/excalidraw-app/data/LocalData.ts
diff options
context:
space:
mode:
Diffstat (limited to 'excalidraw-app/data/LocalData.ts')
-rw-r--r--excalidraw-app/data/LocalData.ts258
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);
+ }
+}