diff options
| author | kj_sh604 | 2026-03-15 16:19:35 -0400 |
|---|---|---|
| committer | kj_sh604 | 2026-03-15 16:19:35 -0400 |
| commit | bfc2cec7d43eb8eaa46dd3f91084932381257059 (patch) | |
| tree | 0857e3aac2cff922826d4871ff54536b26fad6fc /excalidraw-app/data/FileManager.ts | |
| parent | 225db4a7805befe009fe055fc2ef5daedd6c04f9 (diff) | |
refactor: excalidraw-app/
Diffstat (limited to 'excalidraw-app/data/FileManager.ts')
| -rw-r--r-- | excalidraw-app/data/FileManager.ts | 273 |
1 files changed, 273 insertions, 0 deletions
diff --git a/excalidraw-app/data/FileManager.ts b/excalidraw-app/data/FileManager.ts new file mode 100644 index 0000000..f5f4eac --- /dev/null +++ b/excalidraw-app/data/FileManager.ts @@ -0,0 +1,273 @@ +import { CaptureUpdateAction } from "@excalidraw/excalidraw"; +import { compressData } from "@excalidraw/excalidraw/data/encode"; +import { newElementWith } from "@excalidraw/excalidraw/element/mutateElement"; +import { isInitializedImageElement } from "@excalidraw/excalidraw/element/typeChecks"; +import type { + ExcalidrawElement, + ExcalidrawImageElement, + FileId, + InitializedExcalidrawImageElement, +} from "@excalidraw/excalidraw/element/types"; +import { t } from "@excalidraw/excalidraw/i18n"; +import type { + BinaryFileData, + BinaryFileMetadata, + ExcalidrawImperativeAPI, + BinaryFiles, +} from "@excalidraw/excalidraw/types"; + +type FileVersion = Required<BinaryFileData>["version"]; + +export class FileManager { + /** files being fetched */ + private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>(); + 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<ExcalidrawImageElement["fileId"], FileVersion>(); + private erroredFiles_save = new Map< + ExcalidrawImageElement["fileId"], + FileVersion + >(); + + private _getFiles; + private _saveFiles; + + constructor({ + getFiles, + saveFiles, + }: { + getFiles: (fileIds: FileId[]) => Promise<{ + loadedFiles: BinaryFileData[]; + erroredFiles: Map<FileId, true>; + }>; + saveFiles: (data: { addedFiles: Map<FileId, BinaryFileData> }) => Promise<{ + savedFiles: Map<FileId, BinaryFileData>; + erroredFiles: Map<FileId, BinaryFileData>; + }>; + }) { + 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<FileId, BinaryFileData> = 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<FileId, true>; + }> => { + 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<FileId, BinaryFileData>; + 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<BinaryFileMetadata>(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<FileId, true>; + 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; + }), + captureUpdate: CaptureUpdateAction.NEVER, + }); +}; |
