123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185 |
- /**
- * 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 "../../packages/excalidraw/appState";
- import { clearElementsForLocalStorage } from "../../packages/excalidraw/element";
- import {
- ExcalidrawElement,
- FileId,
- } from "../../packages/excalidraw/element/types";
- import {
- AppState,
- BinaryFileData,
- BinaryFiles,
- } from "../../packages/excalidraw/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 };
- },
- });
- }
|