246 lines
7.0 KiB
TypeScript
246 lines
7.0 KiB
TypeScript
/**
|
|
* 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<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, true>();
|
|
const erroredFiles = new Map<FileId, true>();
|
|
|
|
// 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<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);
|
|
}
|
|
}
|