summaryrefslogtreecommitdiffstats
path: root/packages/excalidraw/data/blob.ts
diff options
context:
space:
mode:
Diffstat (limited to 'packages/excalidraw/data/blob.ts')
-rw-r--r--packages/excalidraw/data/blob.ts496
1 files changed, 496 insertions, 0 deletions
diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts
new file mode 100644
index 0000000..2293f86
--- /dev/null
+++ b/packages/excalidraw/data/blob.ts
@@ -0,0 +1,496 @@
+import { nanoid } from "nanoid";
+import { cleanAppStateForExport } from "../appState";
+import { IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
+import { clearElementsForExport } from "../element";
+import type { ExcalidrawElement, FileId } from "../element/types";
+import { CanvasError, ImageSceneDataError } from "../errors";
+import { calculateScrollCenter } from "../scene";
+import { decodeSvgBase64Payload } from "../scene/export";
+import type { AppState, DataURL, LibraryItem } from "../types";
+import type { ValueOf } from "../utility-types";
+import { bytesToHexString, isPromiseLike } from "../utils";
+import { base64ToString, stringToBase64, toByteString } from "./encode";
+import type { FileSystemHandle } from "./filesystem";
+import { nativeFileSystemSupported } from "./filesystem";
+import { isValidExcalidrawData, isValidLibrary } from "./json";
+import { restore, restoreLibraryItems } from "./restore";
+import type { ImportedLibraryData } from "./types";
+
+const parseFileContents = async (blob: Blob | File): Promise<string> => {
+ let contents: string;
+
+ if (blob.type === MIME_TYPES.png) {
+ try {
+ return await (await import("./image")).decodePngMetadata(blob);
+ } catch (error: any) {
+ if (error.message === "INVALID") {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ } else {
+ throw new ImageSceneDataError("Error: cannot restore image");
+ }
+ }
+ } else {
+ if ("text" in Blob) {
+ contents = await blob.text();
+ } else {
+ contents = await new Promise((resolve) => {
+ const reader = new FileReader();
+ reader.readAsText(blob, "utf8");
+ reader.onloadend = () => {
+ if (reader.readyState === FileReader.DONE) {
+ resolve(reader.result as string);
+ }
+ };
+ });
+ }
+ if (blob.type === MIME_TYPES.svg) {
+ try {
+ return decodeSvgBase64Payload({
+ svg: contents,
+ });
+ } catch (error: any) {
+ if (error.message === "INVALID") {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ } else {
+ throw new ImageSceneDataError("Error: cannot restore image");
+ }
+ }
+ }
+ }
+ return contents;
+};
+
+export const getMimeType = (blob: Blob | string): string => {
+ let name: string;
+ if (typeof blob === "string") {
+ name = blob;
+ } else {
+ if (blob.type) {
+ return blob.type;
+ }
+ name = blob.name || "";
+ }
+ if (/\.(excalidraw|json)$/.test(name)) {
+ return MIME_TYPES.json;
+ } else if (/\.png$/.test(name)) {
+ return MIME_TYPES.png;
+ } else if (/\.jpe?g$/.test(name)) {
+ return MIME_TYPES.jpg;
+ } else if (/\.svg$/.test(name)) {
+ return MIME_TYPES.svg;
+ }
+ return "";
+};
+
+export const getFileHandleType = (handle: FileSystemHandle | null) => {
+ if (!handle) {
+ return null;
+ }
+
+ return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
+};
+
+export const isImageFileHandleType = (
+ type: string | null,
+): type is "png" | "svg" => {
+ return type === "png" || type === "svg";
+};
+
+export const isImageFileHandle = (handle: FileSystemHandle | null) => {
+ const type = getFileHandleType(handle);
+ return type === "png" || type === "svg";
+};
+
+export const isSupportedImageFileType = (type: string | null | undefined) => {
+ return !!type && (Object.values(IMAGE_MIME_TYPES) as string[]).includes(type);
+};
+
+export const isSupportedImageFile = (
+ blob: Blob | null | undefined,
+): blob is Blob & { type: ValueOf<typeof IMAGE_MIME_TYPES> } => {
+ const { type } = blob || {};
+ return isSupportedImageFileType(type);
+};
+
+export const loadSceneOrLibraryFromBlob = async (
+ blob: Blob | File,
+ /** @see restore.localAppState */
+ localAppState: AppState | null,
+ localElements: readonly ExcalidrawElement[] | null,
+ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
+ fileHandle?: FileSystemHandle | null,
+) => {
+ const contents = await parseFileContents(blob);
+ let data;
+ try {
+ try {
+ data = JSON.parse(contents);
+ } catch (error: any) {
+ if (isSupportedImageFile(blob)) {
+ throw new ImageSceneDataError(
+ "Image doesn't contain scene",
+ "IMAGE_NOT_CONTAINS_SCENE_DATA",
+ );
+ }
+ throw error;
+ }
+ if (isValidExcalidrawData(data)) {
+ return {
+ type: MIME_TYPES.excalidraw,
+ data: restore(
+ {
+ elements: clearElementsForExport(data.elements || []),
+ appState: {
+ theme: localAppState?.theme,
+ fileHandle: fileHandle || blob.handle || null,
+ ...cleanAppStateForExport(data.appState || {}),
+ ...(localAppState
+ ? calculateScrollCenter(data.elements || [], localAppState)
+ : {}),
+ },
+ files: data.files,
+ },
+ localAppState,
+ localElements,
+ { repairBindings: true, refreshDimensions: false },
+ ),
+ };
+ } else if (isValidLibrary(data)) {
+ return {
+ type: MIME_TYPES.excalidrawlib,
+ data,
+ };
+ }
+ throw new Error("Error: invalid file");
+ } catch (error: any) {
+ if (error instanceof ImageSceneDataError) {
+ throw error;
+ }
+ throw new Error("Error: invalid file");
+ }
+};
+
+export const loadFromBlob = async (
+ blob: Blob,
+ /** @see restore.localAppState */
+ localAppState: AppState | null,
+ localElements: readonly ExcalidrawElement[] | null,
+ /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
+ fileHandle?: FileSystemHandle | null,
+) => {
+ const ret = await loadSceneOrLibraryFromBlob(
+ blob,
+ localAppState,
+ localElements,
+ fileHandle,
+ );
+ if (ret.type !== MIME_TYPES.excalidraw) {
+ throw new Error("Error: invalid file");
+ }
+ return ret.data;
+};
+
+export const parseLibraryJSON = (
+ json: string,
+ defaultStatus: LibraryItem["status"] = "unpublished",
+) => {
+ const data: ImportedLibraryData | undefined = JSON.parse(json);
+ if (!isValidLibrary(data)) {
+ throw new Error("Invalid library");
+ }
+ const libraryItems = data.libraryItems || data.library;
+ return restoreLibraryItems(libraryItems, defaultStatus);
+};
+
+export const loadLibraryFromBlob = async (
+ blob: Blob,
+ defaultStatus: LibraryItem["status"] = "unpublished",
+) => {
+ return parseLibraryJSON(await parseFileContents(blob), defaultStatus);
+};
+
+export const canvasToBlob = async (
+ canvas: HTMLCanvasElement | Promise<HTMLCanvasElement>,
+): Promise<Blob> => {
+ return new Promise(async (resolve, reject) => {
+ try {
+ if (isPromiseLike(canvas)) {
+ canvas = await canvas;
+ }
+ canvas.toBlob((blob) => {
+ if (!blob) {
+ return reject(
+ new CanvasError("Error: Canvas too big", "CANVAS_POSSIBLY_TOO_BIG"),
+ );
+ }
+ resolve(blob);
+ });
+ } catch (error: any) {
+ reject(error);
+ }
+ });
+};
+
+/** generates SHA-1 digest from supplied file (if not supported, falls back
+ to a 40-char base64 random id) */
+export const generateIdFromFile = async (file: File): Promise<FileId> => {
+ try {
+ const hashBuffer = await window.crypto.subtle.digest(
+ "SHA-1",
+ await blobToArrayBuffer(file),
+ );
+ return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
+ } catch (error: any) {
+ console.error(error);
+ // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
+ return nanoid(40) as FileId;
+ }
+};
+
+/** async. For sync variant, use getDataURL_sync */
+export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ const dataURL = reader.result as DataURL;
+ resolve(dataURL);
+ };
+ reader.onerror = (error) => reject(error);
+ reader.readAsDataURL(file);
+ });
+};
+
+export const getDataURL_sync = (
+ data: string | Uint8Array | ArrayBuffer,
+ mimeType: ValueOf<typeof MIME_TYPES>,
+): DataURL => {
+ return `data:${mimeType};base64,${stringToBase64(
+ toByteString(data),
+ true,
+ )}` as DataURL;
+};
+
+export const dataURLToFile = (dataURL: DataURL, filename = "") => {
+ const dataIndexStart = dataURL.indexOf(",");
+ const byteString = atob(dataURL.slice(dataIndexStart + 1));
+ const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
+
+ const ab = new ArrayBuffer(byteString.length);
+ const ia = new Uint8Array(ab);
+ for (let i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i);
+ }
+ return new File([ab], filename, { type: mimeType });
+};
+
+export const dataURLToString = (dataURL: DataURL) => {
+ return base64ToString(dataURL.slice(dataURL.indexOf(",") + 1));
+};
+
+export const resizeImageFile = async (
+ file: File,
+ opts: {
+ /** undefined indicates auto */
+ outputType?: typeof MIME_TYPES["jpg"];
+ maxWidthOrHeight: number;
+ },
+): Promise<File> => {
+ // SVG files shouldn't a can't be resized
+ if (file.type === MIME_TYPES.svg) {
+ return file;
+ }
+
+ const [pica, imageBlobReduce] = await Promise.all([
+ import("pica").then((res) => res.default),
+ // a wrapper for pica for better API
+ import("image-blob-reduce").then((res) => res.default),
+ ]);
+
+ // CRA's minification settings break pica in WebWorkers, so let's disable
+ // them for now
+ // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
+ const reduce = imageBlobReduce({
+ pica: pica({ features: ["js", "wasm"] }),
+ });
+
+ if (opts.outputType) {
+ const { outputType } = opts;
+ reduce._create_blob = function (env) {
+ return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
+ env.out_blob = blob;
+ return env;
+ });
+ };
+ }
+
+ if (!isSupportedImageFile(file)) {
+ throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
+ }
+
+ return new File(
+ [await reduce.toBlob(file, { max: opts.maxWidthOrHeight, alpha: true })],
+ file.name,
+ {
+ type: opts.outputType || file.type,
+ },
+ );
+};
+
+export const SVGStringToFile = (SVGString: string, filename: string = "") => {
+ return new File([new TextEncoder().encode(SVGString)], filename, {
+ type: MIME_TYPES.svg,
+ }) as File & { type: typeof MIME_TYPES.svg };
+};
+
+export const ImageURLToFile = async (
+ imageUrl: string,
+ filename: string = "",
+): Promise<File | undefined> => {
+ let response;
+ try {
+ response = await fetch(imageUrl);
+ } catch (error: any) {
+ throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
+ }
+
+ if (!response.ok) {
+ throw new Error("Error: failed to fetch image", { cause: "FETCH_ERROR" });
+ }
+
+ const blob = await response.blob();
+
+ if (blob.type && isSupportedImageFile(blob)) {
+ const name = filename || blob.name || "";
+ return new File([blob], name, { type: blob.type });
+ }
+
+ throw new Error("Error: unsupported file type", { cause: "UNSUPPORTED" });
+};
+
+export const getFileFromEvent = async (
+ event: React.DragEvent<HTMLDivElement>,
+) => {
+ const file = event.dataTransfer.files.item(0);
+ const fileHandle = await getFileHandle(event);
+
+ return { file: file ? await normalizeFile(file) : null, fileHandle };
+};
+
+export const getFileHandle = async (
+ event: React.DragEvent<HTMLDivElement>,
+): Promise<FileSystemHandle | null> => {
+ if (nativeFileSystemSupported) {
+ try {
+ const item = event.dataTransfer.items[0];
+ const handle: FileSystemHandle | null =
+ (await (item as any).getAsFileSystemHandle()) || null;
+
+ return handle;
+ } catch (error: any) {
+ console.warn(error.name, error.message);
+ return null;
+ }
+ }
+ return null;
+};
+
+/**
+ * attempts to detect if a buffer is a valid image by checking its leading bytes
+ */
+const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
+ let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
+ null;
+
+ const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
+
+ // uint8 leading bytes
+ const headerBytes = {
+ // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
+ png: "137 80 78 71 13 10 26 10 ",
+ // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
+ // jpg is a bit wonky. Checking the first three bytes should be enough,
+ // but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
+ jpg: "255 216 255 ",
+ // https://en.wikipedia.org/wiki/GIF#Example_GIF_file
+ gif: "71 73 70 56 57 97 ",
+ };
+
+ if (first8Bytes === headerBytes.png) {
+ mimeType = MIME_TYPES.png;
+ } else if (first8Bytes.startsWith(headerBytes.jpg)) {
+ mimeType = MIME_TYPES.jpg;
+ } else if (first8Bytes.startsWith(headerBytes.gif)) {
+ mimeType = MIME_TYPES.gif;
+ }
+ return mimeType;
+};
+
+export const createFile = (
+ blob: File | Blob | ArrayBuffer,
+ mimeType: ValueOf<typeof MIME_TYPES>,
+ name: string | undefined,
+) => {
+ return new File([blob], name || "", {
+ type: mimeType,
+ });
+};
+
+/** attempts to detect correct mimeType if none is set, or if an image
+ * has an incorrect extension.
+ * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
+export const normalizeFile = async (file: File) => {
+ if (!file.type) {
+ if (file?.name?.endsWith(".excalidrawlib")) {
+ file = createFile(
+ await blobToArrayBuffer(file),
+ MIME_TYPES.excalidrawlib,
+ file.name,
+ );
+ } else if (file?.name?.endsWith(".excalidraw")) {
+ file = createFile(
+ await blobToArrayBuffer(file),
+ MIME_TYPES.excalidraw,
+ file.name,
+ );
+ } else {
+ const buffer = await blobToArrayBuffer(file);
+ const mimeType = getActualMimeTypeFromImage(buffer);
+ if (mimeType) {
+ file = createFile(buffer, mimeType, file.name);
+ }
+ }
+ // when the file is an image, make sure the extension corresponds to the
+ // actual mimeType (this is an edge case, but happens sometime)
+ } else if (isSupportedImageFile(file)) {
+ const buffer = await blobToArrayBuffer(file);
+ const mimeType = getActualMimeTypeFromImage(buffer);
+ if (mimeType && mimeType !== file.type) {
+ file = createFile(buffer, mimeType, file.name);
+ }
+ }
+
+ return file;
+};
+
+export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
+ if ("arrayBuffer" in blob) {
+ return blob.arrayBuffer();
+ }
+ // Safari
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ if (!event.target?.result) {
+ return reject(new Error("Couldn't convert blob to ArrayBuffer"));
+ }
+ resolve(event.target.result as ArrayBuffer);
+ };
+ reader.readAsArrayBuffer(blob);
+ });
+};