import { StoreAction } from "../../packages/excalidraw"; import { compressData } from "../../packages/excalidraw/data/encode"; import { newElementWith } from "../../packages/excalidraw/element/mutateElement"; import { isInitializedImageElement } from "../../packages/excalidraw/element/typeChecks"; import type { ExcalidrawElement, ExcalidrawImageElement, FileId, InitializedExcalidrawImageElement, } from "../../packages/excalidraw/element/types"; import { t } from "../../packages/excalidraw/i18n"; import type { BinaryFileData, BinaryFileMetadata, ExcalidrawImperativeAPI, BinaryFiles, } from "../../packages/excalidraw/types"; type FileVersion = Required["version"]; export class FileManager { /** files being fetched */ private fetchingFiles = new Map(); private erroredFiles_fetch = new Map< ExcalidrawImageElement["fileId"], true >(); /** files being saved */ private savingFiles = new Map< ExcalidrawImageElement["fileId"], FileVersion >(); /* files already saved to persistent storage */ private savedFiles = new Map(); private erroredFiles_save = new Map< ExcalidrawImageElement["fileId"], FileVersion >(); private _getFiles; private _saveFiles; constructor({ getFiles, saveFiles, }: { getFiles: (fileIds: FileId[]) => Promise<{ loadedFiles: BinaryFileData[]; erroredFiles: Map; }>; saveFiles: (data: { addedFiles: Map }) => Promise<{ savedFiles: Map; erroredFiles: Map; }>; }) { this._getFiles = getFiles; this._saveFiles = saveFiles; } /** * returns whether file is saved/errored, or being processed */ isFileTracked = (id: FileId) => { return ( this.savedFiles.has(id) || this.savingFiles.has(id) || this.fetchingFiles.has(id) || this.erroredFiles_fetch.has(id) || this.erroredFiles_save.has(id) ); }; isFileSavedOrBeingSaved = (file: BinaryFileData) => { const fileVersion = this.getFileVersion(file); return ( this.savedFiles.get(file.id) === fileVersion || this.savingFiles.get(file.id) === fileVersion ); }; getFileVersion = (file: BinaryFileData) => { return file.version ?? 1; }; saveFiles = async ({ elements, files, }: { elements: readonly ExcalidrawElement[]; files: BinaryFiles; }) => { const addedFiles: Map = new Map(); for (const element of elements) { const fileData = isInitializedImageElement(element) && files[element.fileId]; if ( fileData && // NOTE if errored during save, won't retry due to this check !this.isFileSavedOrBeingSaved(fileData) ) { addedFiles.set(element.fileId, files[element.fileId]); this.savingFiles.set(element.fileId, this.getFileVersion(fileData)); } } try { const { savedFiles, erroredFiles } = await this._saveFiles({ addedFiles, }); for (const [fileId, fileData] of savedFiles) { this.savedFiles.set(fileId, this.getFileVersion(fileData)); } for (const [fileId, fileData] of erroredFiles) { this.erroredFiles_save.set(fileId, this.getFileVersion(fileData)); } return { savedFiles, erroredFiles, }; } finally { for (const [fileId] of addedFiles) { this.savingFiles.delete(fileId); } } }; getFiles = async ( ids: FileId[], ): Promise<{ loadedFiles: BinaryFileData[]; erroredFiles: Map; }> => { if (!ids.length) { return { loadedFiles: [], erroredFiles: new Map(), }; } for (const id of ids) { this.fetchingFiles.set(id, true); } try { const { loadedFiles, erroredFiles } = await this._getFiles(ids); for (const file of loadedFiles) { this.savedFiles.set(file.id, this.getFileVersion(file)); } for (const [fileId] of erroredFiles) { this.erroredFiles_fetch.set(fileId, true); } return { loadedFiles, erroredFiles }; } finally { for (const id of ids) { this.fetchingFiles.delete(id); } } }; /** a file element prevents unload only if it's being saved regardless of * its `status`. This ensures that elements who for any reason haven't * beed set to `saved` status don't prevent unload in future sessions. * Technically we should prevent unload when the origin client haven't * yet saved the `status` update to storage, but that should be taken care * of during regular beforeUnload unsaved files check. */ shouldPreventUnload = (elements: readonly ExcalidrawElement[]) => { return elements.some((element) => { return ( isInitializedImageElement(element) && !element.isDeleted && this.savingFiles.has(element.fileId) ); }); }; /** * helper to determine if image element status needs updating */ shouldUpdateImageElementStatus = ( element: ExcalidrawElement, ): element is InitializedExcalidrawImageElement => { return ( isInitializedImageElement(element) && this.savedFiles.has(element.fileId) && element.status === "pending" ); }; reset() { this.fetchingFiles.clear(); this.savingFiles.clear(); this.savedFiles.clear(); this.erroredFiles_fetch.clear(); this.erroredFiles_save.clear(); } } export const encodeFilesForUpload = async ({ files, maxBytes, encryptionKey, }: { files: Map; maxBytes: number; encryptionKey: string; }) => { const processedFiles: { id: FileId; buffer: Uint8Array; }[] = []; for (const [id, fileData] of files) { const buffer = new TextEncoder().encode(fileData.dataURL); const encodedFile = await compressData(buffer, { encryptionKey, metadata: { id, mimeType: fileData.mimeType, created: Date.now(), lastRetrieved: Date.now(), }, }); if (buffer.byteLength > maxBytes) { throw new Error( t("errors.fileTooBig", { maxSize: `${Math.trunc(maxBytes / 1024 / 1024)}MB`, }), ); } processedFiles.push({ id, buffer: encodedFile, }); } return processedFiles; }; export const updateStaleImageStatuses = (params: { excalidrawAPI: ExcalidrawImperativeAPI; erroredFiles: Map; elements: readonly ExcalidrawElement[]; }) => { if (!params.erroredFiles.size) { return; } params.excalidrawAPI.updateScene({ elements: params.excalidrawAPI .getSceneElementsIncludingDeleted() .map((element) => { if ( isInitializedImageElement(element) && params.erroredFiles.has(element.fileId) ) { return newElementWith(element, { status: "error", }); } return element; }), storeAction: StoreAction.UPDATE, }); };