2021-10-21 22:05:48 +02:00
|
|
|
import { nanoid } from "nanoid";
|
2020-09-22 21:51:49 +02:00
|
|
|
import { cleanAppStateForExport } from "../appState";
|
2021-10-21 22:05:48 +02:00
|
|
|
import {
|
|
|
|
ALLOWED_IMAGE_MIME_TYPES,
|
|
|
|
EXPORT_DATA_TYPES,
|
|
|
|
MIME_TYPES,
|
|
|
|
} from "../constants";
|
2020-11-11 15:55:22 +01:00
|
|
|
import { clearElementsForExport } from "../element";
|
2021-10-21 22:05:48 +02:00
|
|
|
import { ExcalidrawElement, FileId } from "../element/types";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { CanvasError } from "../errors";
|
|
|
|
import { t } from "../i18n";
|
|
|
|
import { calculateScrollCenter } from "../scene";
|
2021-10-21 22:05:48 +02:00
|
|
|
import { AppState, DataURL } from "../types";
|
2021-11-07 14:33:21 +01:00
|
|
|
import { bytesToHexString } from "../utils";
|
2021-10-07 13:19:40 +02:00
|
|
|
import { FileSystemHandle } from "./filesystem";
|
2021-03-08 16:37:26 +01:00
|
|
|
import { isValidExcalidrawData } from "./json";
|
2020-12-03 17:03:02 +02:00
|
|
|
import { restore } from "./restore";
|
2021-04-10 19:17:49 +02:00
|
|
|
import { ImportedLibraryData } from "./types";
|
2020-10-15 21:31:21 +02:00
|
|
|
|
2020-12-20 16:11:44 +01:00
|
|
|
const parseFileContents = async (blob: Blob | File) => {
|
2020-07-27 15:29:19 +03:00
|
|
|
let contents: string;
|
2020-10-15 21:31:21 +02:00
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
if (blob.type === MIME_TYPES.png) {
|
2020-10-15 21:31:21 +02:00
|
|
|
try {
|
2020-10-18 23:06:25 +05:30
|
|
|
return await (
|
|
|
|
await import(/* webpackChunkName: "image" */ "./image")
|
|
|
|
).decodePngMetadata(blob);
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2020-10-15 21:31:21 +02:00
|
|
|
if (error.message === "INVALID") {
|
2021-10-21 22:05:48 +02:00
|
|
|
throw new DOMException(
|
|
|
|
t("alerts.imageDoesNotContainScene"),
|
|
|
|
"EncodingError",
|
|
|
|
);
|
2020-10-15 21:31:21 +02:00
|
|
|
} else {
|
2021-10-21 22:05:48 +02:00
|
|
|
throw new DOMException(
|
|
|
|
t("alerts.cannotRestoreFromImage"),
|
|
|
|
"EncodingError",
|
|
|
|
);
|
2020-10-15 21:31:21 +02:00
|
|
|
}
|
2020-10-13 14:47:07 +02:00
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
} else {
|
2020-10-13 14:47:07 +02:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
});
|
|
|
|
}
|
2021-10-21 22:05:48 +02:00
|
|
|
if (blob.type === MIME_TYPES.svg) {
|
2020-10-15 21:31:21 +02:00
|
|
|
try {
|
2020-10-18 23:06:25 +05:30
|
|
|
return await (
|
|
|
|
await import(/* webpackChunkName: "image" */ "./image")
|
|
|
|
).decodeSvgMetadata({
|
2020-10-15 21:31:21 +02:00
|
|
|
svg: contents,
|
|
|
|
});
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2020-10-15 21:31:21 +02:00
|
|
|
if (error.message === "INVALID") {
|
2021-10-21 22:05:48 +02:00
|
|
|
throw new DOMException(
|
|
|
|
t("alerts.imageDoesNotContainScene"),
|
|
|
|
"EncodingError",
|
|
|
|
);
|
2020-10-15 21:31:21 +02:00
|
|
|
} else {
|
2021-10-21 22:05:48 +02:00
|
|
|
throw new DOMException(
|
|
|
|
t("alerts.cannotRestoreFromImage"),
|
|
|
|
"EncodingError",
|
|
|
|
);
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
2020-10-13 14:47:07 +02:00
|
|
|
}
|
|
|
|
}
|
2020-03-07 10:20:38 -05:00
|
|
|
}
|
2020-07-27 15:29:19 +03:00
|
|
|
return contents;
|
|
|
|
};
|
|
|
|
|
2020-10-30 21:01:41 +01:00
|
|
|
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 || "";
|
2020-10-19 10:53:37 +02:00
|
|
|
}
|
|
|
|
if (/\.(excalidraw|json)$/.test(name)) {
|
2021-10-21 22:05:48 +02:00
|
|
|
return MIME_TYPES.json;
|
2020-10-30 21:01:41 +01:00
|
|
|
} else if (/\.png$/.test(name)) {
|
2021-10-21 22:05:48 +02:00
|
|
|
return MIME_TYPES.png;
|
2020-10-30 21:01:41 +01:00
|
|
|
} else if (/\.jpe?g$/.test(name)) {
|
2021-10-21 22:05:48 +02:00
|
|
|
return MIME_TYPES.jpg;
|
2020-10-30 21:01:41 +01:00
|
|
|
} else if (/\.svg$/.test(name)) {
|
2021-10-21 22:05:48 +02:00
|
|
|
return MIME_TYPES.svg;
|
2020-10-19 10:53:37 +02:00
|
|
|
}
|
|
|
|
return "";
|
|
|
|
};
|
|
|
|
|
2021-07-15 09:54:26 -04:00
|
|
|
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";
|
|
|
|
};
|
|
|
|
|
2021-10-21 22:05:48 +02:00
|
|
|
export const isSupportedImageFile = (
|
|
|
|
blob: Blob | null | undefined,
|
|
|
|
): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
|
|
|
|
const { type } = blob || {};
|
|
|
|
return (
|
|
|
|
!!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2020-10-13 13:46:52 +02:00
|
|
|
export const loadFromBlob = async (
|
2020-10-19 10:53:37 +02:00
|
|
|
blob: Blob,
|
2020-10-13 13:46:52 +02:00
|
|
|
/** @see restore.localAppState */
|
|
|
|
localAppState: AppState | null,
|
2021-07-04 22:23:35 +02:00
|
|
|
localElements: readonly ExcalidrawElement[] | null,
|
2020-10-13 13:46:52 +02:00
|
|
|
) => {
|
2020-10-13 14:47:07 +02:00
|
|
|
const contents = await parseFileContents(blob);
|
2020-07-27 17:18:49 +05:30
|
|
|
try {
|
2021-03-08 16:37:26 +01:00
|
|
|
const data = JSON.parse(contents);
|
|
|
|
if (!isValidExcalidrawData(data)) {
|
2020-07-27 17:18:49 +05:30
|
|
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
|
|
|
}
|
2020-12-02 23:57:51 +02:00
|
|
|
const result = restore(
|
2020-10-13 13:46:52 +02:00
|
|
|
{
|
2020-11-11 15:55:22 +01:00
|
|
|
elements: clearElementsForExport(data.elements || []),
|
2020-10-13 13:46:52 +02:00
|
|
|
appState: {
|
2021-03-13 18:58:06 +05:30
|
|
|
theme: localAppState?.theme,
|
2021-07-15 09:54:26 -04:00
|
|
|
fileHandle: blob.handle || null,
|
2020-10-13 13:46:52 +02:00
|
|
|
...cleanAppStateForExport(data.appState || {}),
|
|
|
|
...(localAppState
|
|
|
|
? calculateScrollCenter(data.elements || [], localAppState, null)
|
|
|
|
: {}),
|
|
|
|
},
|
2021-10-21 22:05:48 +02:00
|
|
|
files: data.files,
|
2020-09-22 21:51:49 +02:00
|
|
|
},
|
2020-10-13 13:46:52 +02:00
|
|
|
localAppState,
|
2021-07-04 22:23:35 +02:00
|
|
|
localElements,
|
2020-10-13 13:46:52 +02:00
|
|
|
);
|
2020-12-02 23:57:51 +02:00
|
|
|
|
|
|
|
return result;
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2020-11-05 19:06:18 +02:00
|
|
|
console.error(error.message);
|
2020-07-27 17:18:49 +05:30
|
|
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
|
|
|
}
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-07-27 15:29:19 +03:00
|
|
|
|
2020-10-13 14:47:07 +02:00
|
|
|
export const loadLibraryFromBlob = async (blob: Blob) => {
|
|
|
|
const contents = await parseFileContents(blob);
|
2021-04-10 19:17:49 +02:00
|
|
|
const data: ImportedLibraryData = JSON.parse(contents);
|
2021-03-20 20:20:47 +01:00
|
|
|
if (data.type !== EXPORT_DATA_TYPES.excalidrawLibrary) {
|
2020-07-27 15:29:19 +03:00
|
|
|
throw new Error(t("alerts.couldNotLoadInvalidFile"));
|
|
|
|
}
|
|
|
|
return data;
|
|
|
|
};
|
2020-10-28 20:52:53 +01:00
|
|
|
|
|
|
|
export const canvasToBlob = async (
|
|
|
|
canvas: HTMLCanvasElement,
|
|
|
|
): Promise<Blob> => {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
try {
|
|
|
|
canvas.toBlob((blob) => {
|
|
|
|
if (!blob) {
|
|
|
|
return reject(
|
|
|
|
new CanvasError(
|
|
|
|
t("canvasError.canvasTooBig"),
|
|
|
|
"CANVAS_POSSIBLY_TOO_BIG",
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
resolve(blob);
|
|
|
|
});
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2020-10-28 20:52:53 +01:00
|
|
|
reject(error);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
};
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
/** generates SHA-1 digest from supplied file (if not supported, falls back
|
|
|
|
to a 40-char base64 random id) */
|
2021-11-07 14:33:21 +01:00
|
|
|
export const generateIdFromFile = async (file: File): Promise<FileId> => {
|
2021-10-21 22:05:48 +02:00
|
|
|
try {
|
|
|
|
const hashBuffer = await window.crypto.subtle.digest(
|
|
|
|
"SHA-1",
|
|
|
|
await file.arrayBuffer(),
|
|
|
|
);
|
2021-11-07 14:33:21 +01:00
|
|
|
return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
|
2021-11-02 14:24:16 +02:00
|
|
|
} catch (error: any) {
|
2021-10-21 22:05:48 +02:00
|
|
|
console.error(error);
|
|
|
|
// length 40 to align with the HEX length of SHA-1 (which is 160 bit)
|
2021-11-07 14:33:21 +01:00
|
|
|
return nanoid(40) as FileId;
|
2021-10-21 22:05:48 +02:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
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 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 resizeImageFile = async (
|
|
|
|
file: File,
|
2021-11-26 11:46:13 +01:00
|
|
|
opts: {
|
|
|
|
/** undefined indicates auto */
|
|
|
|
outputType?: typeof MIME_TYPES["jpg"];
|
|
|
|
maxWidthOrHeight: number;
|
|
|
|
},
|
2021-10-21 22:05:48 +02:00
|
|
|
): 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"] }),
|
|
|
|
});
|
|
|
|
|
2021-11-26 11:46:13 +01:00
|
|
|
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;
|
|
|
|
});
|
|
|
|
};
|
|
|
|
}
|
2021-10-21 22:05:48 +02:00
|
|
|
|
|
|
|
if (!isSupportedImageFile(file)) {
|
|
|
|
throw new Error(t("errors.unsupportedFileType"));
|
|
|
|
}
|
|
|
|
|
|
|
|
return new File(
|
2021-11-26 11:46:13 +01:00
|
|
|
[await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
|
2021-10-21 22:05:48 +02:00
|
|
|
file.name,
|
2021-11-26 11:46:13 +01:00
|
|
|
{
|
|
|
|
type: opts.outputType || file.type,
|
|
|
|
},
|
2021-10-21 22:05:48 +02:00
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
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 };
|
|
|
|
};
|