LocalData.ts 7.3 KB

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