FileManager.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274
  1. import { CaptureUpdateAction } from "@excalidraw/excalidraw";
  2. import { compressData } from "@excalidraw/excalidraw/data/encode";
  3. import { newElementWith } from "@excalidraw/element";
  4. import { isInitializedImageElement } from "@excalidraw/element";
  5. import { t } from "@excalidraw/excalidraw/i18n";
  6. import type {
  7. ExcalidrawElement,
  8. ExcalidrawImageElement,
  9. FileId,
  10. InitializedExcalidrawImageElement,
  11. } from "@excalidraw/element/types";
  12. import type {
  13. BinaryFileData,
  14. BinaryFileMetadata,
  15. ExcalidrawImperativeAPI,
  16. BinaryFiles,
  17. } from "@excalidraw/excalidraw/types";
  18. type FileVersion = Required<BinaryFileData>["version"];
  19. export class FileManager {
  20. /** files being fetched */
  21. private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
  22. private erroredFiles_fetch = new Map<
  23. ExcalidrawImageElement["fileId"],
  24. true
  25. >();
  26. /** files being saved */
  27. private savingFiles = new Map<
  28. ExcalidrawImageElement["fileId"],
  29. FileVersion
  30. >();
  31. /* files already saved to persistent storage */
  32. private savedFiles = new Map<ExcalidrawImageElement["fileId"], FileVersion>();
  33. private erroredFiles_save = new Map<
  34. ExcalidrawImageElement["fileId"],
  35. FileVersion
  36. >();
  37. private _getFiles;
  38. private _saveFiles;
  39. constructor({
  40. getFiles,
  41. saveFiles,
  42. }: {
  43. getFiles: (fileIds: FileId[]) => Promise<{
  44. loadedFiles: BinaryFileData[];
  45. erroredFiles: Map<FileId, true>;
  46. }>;
  47. saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{
  48. savedFiles: Map<FileId, BinaryFileData>;
  49. erroredFiles: Map<FileId, BinaryFileData>;
  50. }>;
  51. }) {
  52. this._getFiles = getFiles;
  53. this._saveFiles = saveFiles;
  54. }
  55. /**
  56. * returns whether file is saved/errored, or being processed
  57. */
  58. isFileTracked = (id: FileId) => {
  59. return (
  60. this.savedFiles.has(id) ||
  61. this.savingFiles.has(id) ||
  62. this.fetchingFiles.has(id) ||
  63. this.erroredFiles_fetch.has(id) ||
  64. this.erroredFiles_save.has(id)
  65. );
  66. };
  67. isFileSavedOrBeingSaved = (file: BinaryFileData) => {
  68. const fileVersion = this.getFileVersion(file);
  69. return (
  70. this.savedFiles.get(file.id) === fileVersion ||
  71. this.savingFiles.get(file.id) === fileVersion
  72. );
  73. };
  74. getFileVersion = (file: BinaryFileData) => {
  75. return file.version ?? 1;
  76. };
  77. saveFiles = async ({
  78. elements,
  79. files,
  80. }: {
  81. elements: readonly ExcalidrawElement[];
  82. files: BinaryFiles;
  83. }) => {
  84. const addedFiles: Map<FileId, BinaryFileData> = new Map();
  85. for (const element of elements) {
  86. const fileData =
  87. isInitializedImageElement(element) && files[element.fileId];
  88. if (
  89. fileData &&
  90. // NOTE if errored during save, won't retry due to this check
  91. !this.isFileSavedOrBeingSaved(fileData)
  92. ) {
  93. addedFiles.set(element.fileId, files[element.fileId]);
  94. this.savingFiles.set(element.fileId, this.getFileVersion(fileData));
  95. }
  96. }
  97. try {
  98. const { savedFiles, erroredFiles } = await this._saveFiles({
  99. addedFiles,
  100. });
  101. for (const [fileId, fileData] of savedFiles) {
  102. this.savedFiles.set(fileId, this.getFileVersion(fileData));
  103. }
  104. for (const [fileId, fileData] of erroredFiles) {
  105. this.erroredFiles_save.set(fileId, this.getFileVersion(fileData));
  106. }
  107. return {
  108. savedFiles,
  109. erroredFiles,
  110. };
  111. } finally {
  112. for (const [fileId] of addedFiles) {
  113. this.savingFiles.delete(fileId);
  114. }
  115. }
  116. };
  117. getFiles = async (
  118. ids: FileId[],
  119. ): Promise<{
  120. loadedFiles: BinaryFileData[];
  121. erroredFiles: Map<FileId, true>;
  122. }> => {
  123. if (!ids.length) {
  124. return {
  125. loadedFiles: [],
  126. erroredFiles: new Map(),
  127. };
  128. }
  129. for (const id of ids) {
  130. this.fetchingFiles.set(id, true);
  131. }
  132. try {
  133. const { loadedFiles, erroredFiles } = await this._getFiles(ids);
  134. for (const file of loadedFiles) {
  135. this.savedFiles.set(file.id, this.getFileVersion(file));
  136. }
  137. for (const [fileId] of erroredFiles) {
  138. this.erroredFiles_fetch.set(fileId, true);
  139. }
  140. return { loadedFiles, erroredFiles };
  141. } finally {
  142. for (const id of ids) {
  143. this.fetchingFiles.delete(id);
  144. }
  145. }
  146. };
  147. /** a file element prevents unload only if it's being saved regardless of
  148. * its `status`. This ensures that elements who for any reason haven't
  149. * beed set to `saved` status don't prevent unload in future sessions.
  150. * Technically we should prevent unload when the origin client haven't
  151. * yet saved the `status` update to storage, but that should be taken care
  152. * of during regular beforeUnload unsaved files check.
  153. */
  154. shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => {
  155. return elements.some((element) => {
  156. return (
  157. isInitializedImageElement(element) &&
  158. !element.isDeleted &&
  159. this.savingFiles.has(element.fileId)
  160. );
  161. });
  162. };
  163. /**
  164. * helper to determine if image element status needs updating
  165. */
  166. shouldUpdateImageElementStatus = (
  167. element: ExcalidrawElement,
  168. ): element is InitializedExcalidrawImageElement => {
  169. return (
  170. isInitializedImageElement(element) &&
  171. this.savedFiles.has(element.fileId) &&
  172. element.status === "pending"
  173. );
  174. };
  175. reset() {
  176. this.fetchingFiles.clear();
  177. this.savingFiles.clear();
  178. this.savedFiles.clear();
  179. this.erroredFiles_fetch.clear();
  180. this.erroredFiles_save.clear();
  181. }
  182. }
  183. export const encodeFilesForUpload = async ({
  184. files,
  185. maxBytes,
  186. encryptionKey,
  187. }: {
  188. files: Map<FileId, BinaryFileData>;
  189. maxBytes: number;
  190. encryptionKey: string;
  191. }) => {
  192. const processedFiles: {
  193. id: FileId;
  194. buffer: Uint8Array;
  195. }[] = [];
  196. for (const [id, fileData] of files) {
  197. const buffer = new TextEncoder().encode(fileData.dataURL);
  198. const encodedFile = await compressData<BinaryFileMetadata>(buffer, {
  199. encryptionKey,
  200. metadata: {
  201. id,
  202. mimeType: fileData.mimeType,
  203. created: Date.now(),
  204. lastRetrieved: Date.now(),
  205. },
  206. });
  207. if (buffer.byteLength > maxBytes) {
  208. throw new Error(
  209. t("errors.fileTooBig", {
  210. maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`,
  211. }),
  212. );
  213. }
  214. processedFiles.push({
  215. id,
  216. buffer: encodedFile,
  217. });
  218. }
  219. return processedFiles;
  220. };
  221. export const updateStaleImageStatuses = (params: {
  222. excalidrawAPI: ExcalidrawImperativeAPI;
  223. erroredFiles: Map<FileId, true>;
  224. elements: readonly ExcalidrawElement[];
  225. }) => {
  226. if (!params.erroredFiles.size) {
  227. return;
  228. }
  229. params.excalidrawAPI.updateScene({
  230. elements: params.excalidrawAPI
  231. .getSceneElementsIncludingDeleted()
  232. .map((element) => {
  233. if (
  234. isInitializedImageElement(element) &&
  235. params.erroredFiles.has(element.fileId)
  236. ) {
  237. return newElementWith(element, {
  238. status: "error",
  239. });
  240. }
  241. return element;
  242. }),
  243. captureUpdate: CaptureUpdateAction.NEVER,
  244. });
  245. };