mirror of
https://github.com/excalidraw/excalidraw.git
synced 2024-11-10 11:35:52 +01:00
015b46ab23
Improved Store API and improved handling of actions to eliminate potential concurrency issues
245 lines
6.1 KiB
TypeScript
245 lines
6.1 KiB
TypeScript
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 {
|
|
ExcalidrawElement,
|
|
ExcalidrawImageElement,
|
|
FileId,
|
|
InitializedExcalidrawImageElement,
|
|
} from "../../packages/excalidraw/element/types";
|
|
import { t } from "../../packages/excalidraw/i18n";
|
|
import {
|
|
BinaryFileData,
|
|
BinaryFileMetadata,
|
|
ExcalidrawImperativeAPI,
|
|
BinaryFiles,
|
|
} from "../../packages/excalidraw/types";
|
|
|
|
export class FileManager {
|
|
/** files being fetched */
|
|
private fetchingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
|
/** files being saved */
|
|
private savingFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
|
/* files already saved to persistent storage */
|
|
private savedFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
|
private erroredFiles = new Map<ExcalidrawImageElement["fileId"], true>();
|
|
|
|
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, true>;
|
|
erroredFiles: Map<FileId, true>;
|
|
}>;
|
|
}) {
|
|
this._getFiles = getFiles;
|
|
this._saveFiles = saveFiles;
|
|
}
|
|
|
|
/**
|
|
* returns whether file is already saved or being processed
|
|
*/
|
|
isFileHandled = (id: FileId) => {
|
|
return (
|
|
this.savedFiles.has(id) ||
|
|
this.fetchingFiles.has(id) ||
|
|
this.savingFiles.has(id) ||
|
|
this.erroredFiles.has(id)
|
|
);
|
|
};
|
|
|
|
isFileSaved = (id: FileId) => {
|
|
return this.savedFiles.has(id);
|
|
};
|
|
|
|
saveFiles = async ({
|
|
elements,
|
|
files,
|
|
}: {
|
|
elements: readonly ExcalidrawElement[];
|
|
files: BinaryFiles;
|
|
}) => {
|
|
const addedFiles: Map<FileId, BinaryFileData> = new Map();
|
|
|
|
for (const element of elements) {
|
|
if (
|
|
isInitializedImageElement(element) &&
|
|
files[element.fileId] &&
|
|
!this.isFileHandled(element.fileId)
|
|
) {
|
|
addedFiles.set(element.fileId, files[element.fileId]);
|
|
this.savingFiles.set(element.fileId, true);
|
|
}
|
|
}
|
|
|
|
try {
|
|
const { savedFiles, erroredFiles } = await this._saveFiles({
|
|
addedFiles,
|
|
});
|
|
|
|
for (const [fileId] of savedFiles) {
|
|
this.savedFiles.set(fileId, true);
|
|
}
|
|
|
|
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, true);
|
|
}
|
|
for (const [fileId] of erroredFiles) {
|
|
this.erroredFiles.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.isFileSaved(element.fileId) &&
|
|
element.status === "pending"
|
|
);
|
|
};
|
|
|
|
reset() {
|
|
this.fetchingFiles.clear();
|
|
this.savingFiles.clear();
|
|
this.savedFiles.clear();
|
|
this.erroredFiles.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;
|
|
}),
|
|
storeAction: StoreAction.UPDATE,
|
|
});
|
|
};
|