LocalData.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. /**
  2. * This file deals with saving data state (appState, elements, images, ...)
  3. * locally to the browser.
  4. *
  5. * Notes:
  6. *
  7. * - DataState refers to full state of the app: appState, elements, images,
  8. * though some state is saved separately (collab username, library) for one
  9. * reason or another. We also save different data to different storage
  10. * (localStorage, indexedDB).
  11. */
  12. import { clearAppStateForLocalStorage } from "@excalidraw/excalidraw/appState";
  13. import {
  14. CANVAS_SEARCH_TAB,
  15. DEFAULT_SIDEBAR,
  16. debounce,
  17. } from "@excalidraw/common";
  18. import {
  19. createStore,
  20. entries,
  21. del,
  22. getMany,
  23. set,
  24. setMany,
  25. get,
  26. } from "idb-keyval";
  27. import { appJotaiStore, atom } from "excalidraw-app/app-jotai";
  28. import { getNonDeletedElements } from "@excalidraw/element";
  29. import type { LibraryPersistedData } from "@excalidraw/excalidraw/data/library";
  30. import type { ImportedDataState } from "@excalidraw/excalidraw/data/types";
  31. import type { ExcalidrawElement, FileId } from "@excalidraw/element/types";
  32. import type {
  33. AppState,
  34. BinaryFileData,
  35. BinaryFiles,
  36. } from "@excalidraw/excalidraw/types";
  37. import type { MaybePromise } from "@excalidraw/common/utility-types";
  38. import { SAVE_TO_LOCAL_STORAGE_TIMEOUT, STORAGE_KEYS } from "../app_constants";
  39. import { FileManager } from "./FileManager";
  40. import { Locker } from "./Locker";
  41. import { updateBrowserStateVersion } from "./tabSync";
  42. const filesStore = createStore("files-db", "files-store");
  43. export const localStorageQuotaExceededAtom = atom(false);
  44. class LocalFileManager extends FileManager {
  45. clearObsoleteFiles = async (opts: { currentFileIds: FileId[] }) => {
  46. await entries(filesStore).then((entries) => {
  47. for (const [id, imageData] of entries as [FileId, BinaryFileData][]) {
  48. // if image is unused (not on canvas) & is older than 1 day, delete it
  49. // from storage. We check `lastRetrieved` we care about the last time
  50. // the image was used (loaded on canvas), not when it was initially
  51. // created.
  52. if (
  53. (!imageData.lastRetrieved ||
  54. Date.now() - imageData.lastRetrieved > 24 * 3600 * 1000) &&
  55. !opts.currentFileIds.includes(id as FileId)
  56. ) {
  57. del(id, filesStore);
  58. }
  59. }
  60. });
  61. };
  62. }
  63. const saveDataStateToLocalStorage = (
  64. elements: readonly ExcalidrawElement[],
  65. appState: AppState,
  66. ) => {
  67. const localStorageQuotaExceeded = appJotaiStore.get(
  68. localStorageQuotaExceededAtom,
  69. );
  70. try {
  71. const _appState = clearAppStateForLocalStorage(appState);
  72. if (
  73. _appState.openSidebar?.name === DEFAULT_SIDEBAR.name &&
  74. _appState.openSidebar.tab === CANVAS_SEARCH_TAB
  75. ) {
  76. _appState.openSidebar = null;
  77. }
  78. localStorage.setItem(
  79. STORAGE_KEYS.LOCAL_STORAGE_ELEMENTS,
  80. JSON.stringify(getNonDeletedElements(elements)),
  81. );
  82. localStorage.setItem(
  83. STORAGE_KEYS.LOCAL_STORAGE_APP_STATE,
  84. JSON.stringify(_appState),
  85. );
  86. updateBrowserStateVersion(STORAGE_KEYS.VERSION_DATA_STATE);
  87. if (localStorageQuotaExceeded) {
  88. appJotaiStore.set(localStorageQuotaExceededAtom, false);
  89. }
  90. } catch (error: any) {
  91. // Unable to access window.localStorage
  92. console.error(error);
  93. if (isQuotaExceededError(error) && !localStorageQuotaExceeded) {
  94. appJotaiStore.set(localStorageQuotaExceededAtom, true);
  95. }
  96. }
  97. };
  98. const isQuotaExceededError = (error: any) => {
  99. return error instanceof DOMException && error.name === "QuotaExceededError";
  100. };
  101. type SavingLockTypes = "collaboration";
  102. export class LocalData {
  103. private static _save = debounce(
  104. async (
  105. elements: readonly ExcalidrawElement[],
  106. appState: AppState,
  107. files: BinaryFiles,
  108. onFilesSaved: () => void,
  109. ) => {
  110. saveDataStateToLocalStorage(elements, appState);
  111. await this.fileStorage.saveFiles({
  112. elements,
  113. files,
  114. });
  115. onFilesSaved();
  116. },
  117. SAVE_TO_LOCAL_STORAGE_TIMEOUT,
  118. );
  119. /** Saves DataState, including files. Bails if saving is paused */
  120. static save = (
  121. elements: readonly ExcalidrawElement[],
  122. appState: AppState,
  123. files: BinaryFiles,
  124. onFilesSaved: () => void,
  125. ) => {
  126. // we need to make the `isSavePaused` check synchronously (undebounced)
  127. if (!this.isSavePaused()) {
  128. this._save(elements, appState, files, onFilesSaved);
  129. }
  130. };
  131. static flushSave = () => {
  132. this._save.flush();
  133. };
  134. private static locker = new Locker<SavingLockTypes>();
  135. static pauseSave = (lockType: SavingLockTypes) => {
  136. this.locker.lock(lockType);
  137. };
  138. static resumeSave = (lockType: SavingLockTypes) => {
  139. this.locker.unlock(lockType);
  140. };
  141. static isSavePaused = () => {
  142. return document.hidden || this.locker.isLocked();
  143. };
  144. // ---------------------------------------------------------------------------
  145. static fileStorage = new LocalFileManager({
  146. getFiles(ids) {
  147. return getMany(ids, filesStore).then(
  148. async (filesData: (BinaryFileData | undefined)[]) => {
  149. const loadedFiles: BinaryFileData[] = [];
  150. const erroredFiles = new Map<FileId, true>();
  151. const filesToSave: [FileId, BinaryFileData][] = [];
  152. filesData.forEach((data, index) => {
  153. const id = ids[index];
  154. if (data) {
  155. const _data: BinaryFileData = {
  156. ...data,
  157. lastRetrieved: Date.now(),
  158. };
  159. filesToSave.push([id, _data]);
  160. loadedFiles.push(_data);
  161. } else {
  162. erroredFiles.set(id, true);
  163. }
  164. });
  165. try {
  166. // save loaded files back to storage with updated `lastRetrieved`
  167. setMany(filesToSave, filesStore);
  168. } catch (error) {
  169. console.warn(error);
  170. }
  171. return { loadedFiles, erroredFiles };
  172. },
  173. );
  174. },
  175. async saveFiles({ addedFiles }) {
  176. const savedFiles = new Map<FileId, BinaryFileData>();
  177. const erroredFiles = new Map<FileId, BinaryFileData>();
  178. // before we use `storage` event synchronization, let's update the flag
  179. // optimistically. Hopefully nothing fails, and an IDB read executed
  180. // before an IDB write finishes will read the latest value.
  181. updateBrowserStateVersion(STORAGE_KEYS.VERSION_FILES);
  182. await Promise.all(
  183. [...addedFiles].map(async ([id, fileData]) => {
  184. try {
  185. await set(id, fileData, filesStore);
  186. savedFiles.set(id, fileData);
  187. } catch (error: any) {
  188. console.error(error);
  189. erroredFiles.set(id, fileData);
  190. }
  191. }),
  192. );
  193. return { savedFiles, erroredFiles };
  194. },
  195. });
  196. }
  197. export class LibraryIndexedDBAdapter {
  198. /** IndexedDB database and store name */
  199. private static idb_name = STORAGE_KEYS.IDB_LIBRARY;
  200. /** library data store key */
  201. private static key = "libraryData";
  202. private static store = createStore(
  203. `${LibraryIndexedDBAdapter.idb_name}-db`,
  204. `${LibraryIndexedDBAdapter.idb_name}-store`,
  205. );
  206. static async load() {
  207. const IDBData = await get<LibraryPersistedData>(
  208. LibraryIndexedDBAdapter.key,
  209. LibraryIndexedDBAdapter.store,
  210. );
  211. return IDBData || null;
  212. }
  213. static save(data: LibraryPersistedData): MaybePromise<void> {
  214. return set(
  215. LibraryIndexedDBAdapter.key,
  216. data,
  217. LibraryIndexedDBAdapter.store,
  218. );
  219. }
  220. }
  221. /** LS Adapter used only for migrating LS library data
  222. * to indexedDB */
  223. export class LibraryLocalStorageMigrationAdapter {
  224. static load() {
  225. const LSData = localStorage.getItem(
  226. STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY,
  227. );
  228. if (LSData != null) {
  229. const libraryItems: ImportedDataState["libraryItems"] =
  230. JSON.parse(LSData);
  231. if (libraryItems) {
  232. return { libraryItems };
  233. }
  234. }
  235. return null;
  236. }
  237. static clear() {
  238. localStorage.removeItem(STORAGE_KEYS.__LEGACY_LOCAL_STORAGE_LIBRARY);
  239. }
  240. }