blob.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445
  1. import { nanoid } from "nanoid";
  2. import { cleanAppStateForExport } from "../appState";
  3. import { ALLOWED_IMAGE_MIME_TYPES, MIME_TYPES } from "../constants";
  4. import { clearElementsForExport } from "../element";
  5. import { ExcalidrawElement, FileId } from "../element/types";
  6. import { CanvasError } from "../errors";
  7. import { t } from "../i18n";
  8. import { calculateScrollCenter } from "../scene";
  9. import { AppState, DataURL, LibraryItem } from "../types";
  10. import { bytesToHexString } from "../utils";
  11. import { FileSystemHandle, nativeFileSystemSupported } from "./filesystem";
  12. import { isValidExcalidrawData, isValidLibrary } from "./json";
  13. import { restore, restoreLibraryItems } from "./restore";
  14. import { ImportedLibraryData } from "./types";
  15. const parseFileContents = async (blob: Blob | File) => {
  16. let contents: string;
  17. if (blob.type === MIME_TYPES.png) {
  18. try {
  19. return await (
  20. await import(/* webpackChunkName: "image" */ "./image")
  21. ).decodePngMetadata(blob);
  22. } catch (error: any) {
  23. if (error.message === "INVALID") {
  24. throw new DOMException(
  25. t("alerts.imageDoesNotContainScene"),
  26. "EncodingError",
  27. );
  28. } else {
  29. throw new DOMException(
  30. t("alerts.cannotRestoreFromImage"),
  31. "EncodingError",
  32. );
  33. }
  34. }
  35. } else {
  36. if ("text" in Blob) {
  37. contents = await blob.text();
  38. } else {
  39. contents = await new Promise((resolve) => {
  40. const reader = new FileReader();
  41. reader.readAsText(blob, "utf8");
  42. reader.onloadend = () => {
  43. if (reader.readyState === FileReader.DONE) {
  44. resolve(reader.result as string);
  45. }
  46. };
  47. });
  48. }
  49. if (blob.type === MIME_TYPES.svg) {
  50. try {
  51. return await (
  52. await import(/* webpackChunkName: "image" */ "./image")
  53. ).decodeSvgMetadata({
  54. svg: contents,
  55. });
  56. } catch (error: any) {
  57. if (error.message === "INVALID") {
  58. throw new DOMException(
  59. t("alerts.imageDoesNotContainScene"),
  60. "EncodingError",
  61. );
  62. } else {
  63. throw new DOMException(
  64. t("alerts.cannotRestoreFromImage"),
  65. "EncodingError",
  66. );
  67. }
  68. }
  69. }
  70. }
  71. return contents;
  72. };
  73. export const getMimeType = (blob: Blob | string): string => {
  74. let name: string;
  75. if (typeof blob === "string") {
  76. name = blob;
  77. } else {
  78. if (blob.type) {
  79. return blob.type;
  80. }
  81. name = blob.name || "";
  82. }
  83. if (/\.(excalidraw|json)$/.test(name)) {
  84. return MIME_TYPES.json;
  85. } else if (/\.png$/.test(name)) {
  86. return MIME_TYPES.png;
  87. } else if (/\.jpe?g$/.test(name)) {
  88. return MIME_TYPES.jpg;
  89. } else if (/\.svg$/.test(name)) {
  90. return MIME_TYPES.svg;
  91. }
  92. return "";
  93. };
  94. export const getFileHandleType = (handle: FileSystemHandle | null) => {
  95. if (!handle) {
  96. return null;
  97. }
  98. return handle.name.match(/\.(json|excalidraw|png|svg)$/)?.[1] || null;
  99. };
  100. export const isImageFileHandleType = (
  101. type: string | null,
  102. ): type is "png" | "svg" => {
  103. return type === "png" || type === "svg";
  104. };
  105. export const isImageFileHandle = (handle: FileSystemHandle | null) => {
  106. const type = getFileHandleType(handle);
  107. return type === "png" || type === "svg";
  108. };
  109. export const isSupportedImageFile = (
  110. blob: Blob | null | undefined,
  111. ): blob is Blob & { type: typeof ALLOWED_IMAGE_MIME_TYPES[number] } => {
  112. const { type } = blob || {};
  113. return (
  114. !!type && (ALLOWED_IMAGE_MIME_TYPES as readonly string[]).includes(type)
  115. );
  116. };
  117. export const loadSceneOrLibraryFromBlob = async (
  118. blob: Blob | File,
  119. /** @see restore.localAppState */
  120. localAppState: AppState | null,
  121. localElements: readonly ExcalidrawElement[] | null,
  122. /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
  123. fileHandle?: FileSystemHandle | null,
  124. ) => {
  125. const contents = await parseFileContents(blob);
  126. try {
  127. const data = JSON.parse(contents);
  128. if (isValidExcalidrawData(data)) {
  129. return {
  130. type: MIME_TYPES.excalidraw,
  131. data: restore(
  132. {
  133. elements: clearElementsForExport(data.elements || []),
  134. appState: {
  135. theme: localAppState?.theme,
  136. fileHandle: fileHandle || blob.handle || null,
  137. ...cleanAppStateForExport(data.appState || {}),
  138. ...(localAppState
  139. ? calculateScrollCenter(
  140. data.elements || [],
  141. localAppState,
  142. null,
  143. )
  144. : {}),
  145. },
  146. files: data.files,
  147. },
  148. localAppState,
  149. localElements,
  150. ),
  151. };
  152. } else if (isValidLibrary(data)) {
  153. return {
  154. type: MIME_TYPES.excalidrawlib,
  155. data,
  156. };
  157. }
  158. throw new Error(t("alerts.couldNotLoadInvalidFile"));
  159. } catch (error: any) {
  160. console.error(error.message);
  161. throw new Error(t("alerts.couldNotLoadInvalidFile"));
  162. }
  163. };
  164. export const loadFromBlob = async (
  165. blob: Blob,
  166. /** @see restore.localAppState */
  167. localAppState: AppState | null,
  168. localElements: readonly ExcalidrawElement[] | null,
  169. /** FileSystemHandle. Defaults to `blob.handle` if defined, otherwise null. */
  170. fileHandle?: FileSystemHandle | null,
  171. ) => {
  172. const ret = await loadSceneOrLibraryFromBlob(
  173. blob,
  174. localAppState,
  175. localElements,
  176. fileHandle,
  177. );
  178. if (ret.type !== MIME_TYPES.excalidraw) {
  179. throw new Error(t("alerts.couldNotLoadInvalidFile"));
  180. }
  181. return ret.data;
  182. };
  183. export const loadLibraryFromBlob = async (
  184. blob: Blob,
  185. defaultStatus: LibraryItem["status"] = "unpublished",
  186. ) => {
  187. const contents = await parseFileContents(blob);
  188. const data: ImportedLibraryData | undefined = JSON.parse(contents);
  189. if (!isValidLibrary(data)) {
  190. throw new Error("Invalid library");
  191. }
  192. const libraryItems = data.libraryItems || data.library;
  193. return restoreLibraryItems(libraryItems, defaultStatus);
  194. };
  195. export const canvasToBlob = async (
  196. canvas: HTMLCanvasElement,
  197. ): Promise<Blob> => {
  198. return new Promise((resolve, reject) => {
  199. try {
  200. canvas.toBlob((blob) => {
  201. if (!blob) {
  202. return reject(
  203. new CanvasError(
  204. t("canvasError.canvasTooBig"),
  205. "CANVAS_POSSIBLY_TOO_BIG",
  206. ),
  207. );
  208. }
  209. resolve(blob);
  210. });
  211. } catch (error: any) {
  212. reject(error);
  213. }
  214. });
  215. };
  216. /** generates SHA-1 digest from supplied file (if not supported, falls back
  217. to a 40-char base64 random id) */
  218. export const generateIdFromFile = async (file: File): Promise<FileId> => {
  219. try {
  220. const hashBuffer = await window.crypto.subtle.digest(
  221. "SHA-1",
  222. await blobToArrayBuffer(file),
  223. );
  224. return bytesToHexString(new Uint8Array(hashBuffer)) as FileId;
  225. } catch (error: any) {
  226. console.error(error);
  227. // length 40 to align with the HEX length of SHA-1 (which is 160 bit)
  228. return nanoid(40) as FileId;
  229. }
  230. };
  231. export const getDataURL = async (file: Blob | File): Promise<DataURL> => {
  232. return new Promise((resolve, reject) => {
  233. const reader = new FileReader();
  234. reader.onload = () => {
  235. const dataURL = reader.result as DataURL;
  236. resolve(dataURL);
  237. };
  238. reader.onerror = (error) => reject(error);
  239. reader.readAsDataURL(file);
  240. });
  241. };
  242. export const dataURLToFile = (dataURL: DataURL, filename = "") => {
  243. const dataIndexStart = dataURL.indexOf(",");
  244. const byteString = atob(dataURL.slice(dataIndexStart + 1));
  245. const mimeType = dataURL.slice(0, dataIndexStart).split(":")[1].split(";")[0];
  246. const ab = new ArrayBuffer(byteString.length);
  247. const ia = new Uint8Array(ab);
  248. for (let i = 0; i < byteString.length; i++) {
  249. ia[i] = byteString.charCodeAt(i);
  250. }
  251. return new File([ab], filename, { type: mimeType });
  252. };
  253. export const resizeImageFile = async (
  254. file: File,
  255. opts: {
  256. /** undefined indicates auto */
  257. outputType?: typeof MIME_TYPES["jpg"];
  258. maxWidthOrHeight: number;
  259. },
  260. ): Promise<File> => {
  261. // SVG files shouldn't a can't be resized
  262. if (file.type === MIME_TYPES.svg) {
  263. return file;
  264. }
  265. const [pica, imageBlobReduce] = await Promise.all([
  266. import("pica").then((res) => res.default),
  267. // a wrapper for pica for better API
  268. import("image-blob-reduce").then((res) => res.default),
  269. ]);
  270. // CRA's minification settings break pica in WebWorkers, so let's disable
  271. // them for now
  272. // https://github.com/nodeca/image-blob-reduce/issues/21#issuecomment-757365513
  273. const reduce = imageBlobReduce({
  274. pica: pica({ features: ["js", "wasm"] }),
  275. });
  276. if (opts.outputType) {
  277. const { outputType } = opts;
  278. reduce._create_blob = function (env) {
  279. return this.pica.toBlob(env.out_canvas, outputType, 0.8).then((blob) => {
  280. env.out_blob = blob;
  281. return env;
  282. });
  283. };
  284. }
  285. if (!isSupportedImageFile(file)) {
  286. throw new Error(t("errors.unsupportedFileType"));
  287. }
  288. return new File(
  289. [await reduce.toBlob(file, { max: opts.maxWidthOrHeight })],
  290. file.name,
  291. {
  292. type: opts.outputType || file.type,
  293. },
  294. );
  295. };
  296. export const SVGStringToFile = (SVGString: string, filename: string = "") => {
  297. return new File([new TextEncoder().encode(SVGString)], filename, {
  298. type: MIME_TYPES.svg,
  299. }) as File & { type: typeof MIME_TYPES.svg };
  300. };
  301. export const getFileFromEvent = async (
  302. event: React.DragEvent<HTMLDivElement>,
  303. ) => {
  304. const file = event.dataTransfer.files.item(0);
  305. const fileHandle = await getFileHandle(event);
  306. return { file: file ? await normalizeFile(file) : null, fileHandle };
  307. };
  308. export const getFileHandle = async (
  309. event: React.DragEvent<HTMLDivElement>,
  310. ): Promise<FileSystemHandle | null> => {
  311. if (nativeFileSystemSupported) {
  312. try {
  313. const item = event.dataTransfer.items[0];
  314. const handle: FileSystemHandle | null =
  315. (await (item as any).getAsFileSystemHandle()) || null;
  316. return handle;
  317. } catch (error: any) {
  318. console.warn(error.name, error.message);
  319. return null;
  320. }
  321. }
  322. return null;
  323. };
  324. /**
  325. * attemps to detect if a buffer is a valid image by checking its leading bytes
  326. */
  327. const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => {
  328. let mimeType: ValueOf<Pick<typeof MIME_TYPES, "png" | "jpg" | "gif">> | null =
  329. null;
  330. const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `;
  331. // uint8 leading bytes
  332. const headerBytes = {
  333. // https://en.wikipedia.org/wiki/Portable_Network_Graphics#File_header
  334. png: "137 80 78 71 13 10 26 10 ",
  335. // https://en.wikipedia.org/wiki/JPEG#Syntax_and_structure
  336. // jpg is a bit wonky. Checking the first three bytes should be enough,
  337. // but may yield false positives. (https://stackoverflow.com/a/23360709/927631)
  338. jpg: "255 216 255 ",
  339. // https://en.wikipedia.org/wiki/GIF#Example_GIF_file
  340. gif: "71 73 70 56 57 97 ",
  341. };
  342. if (first8Bytes === headerBytes.png) {
  343. mimeType = MIME_TYPES.png;
  344. } else if (first8Bytes.startsWith(headerBytes.jpg)) {
  345. mimeType = MIME_TYPES.jpg;
  346. } else if (first8Bytes.startsWith(headerBytes.gif)) {
  347. mimeType = MIME_TYPES.gif;
  348. }
  349. return mimeType;
  350. };
  351. export const createFile = (
  352. blob: File | Blob | ArrayBuffer,
  353. mimeType: ValueOf<typeof MIME_TYPES>,
  354. name: string | undefined,
  355. ) => {
  356. return new File([blob], name || "", {
  357. type: mimeType,
  358. });
  359. };
  360. /** attemps to detect correct mimeType if none is set, or if an image
  361. * has an incorrect extension.
  362. * Note: doesn't handle missing .excalidraw/.excalidrawlib extension */
  363. export const normalizeFile = async (file: File) => {
  364. if (!file.type) {
  365. if (file?.name?.endsWith(".excalidrawlib")) {
  366. file = createFile(
  367. await blobToArrayBuffer(file),
  368. MIME_TYPES.excalidrawlib,
  369. file.name,
  370. );
  371. } else if (file?.name?.endsWith(".excalidraw")) {
  372. file = createFile(
  373. await blobToArrayBuffer(file),
  374. MIME_TYPES.excalidraw,
  375. file.name,
  376. );
  377. } else {
  378. const buffer = await blobToArrayBuffer(file);
  379. const mimeType = getActualMimeTypeFromImage(buffer);
  380. if (mimeType) {
  381. file = createFile(buffer, mimeType, file.name);
  382. }
  383. }
  384. // when the file is an image, make sure the extension corresponds to the
  385. // actual mimeType (this is an edge case, but happens sometime)
  386. } else if (isSupportedImageFile(file)) {
  387. const buffer = await blobToArrayBuffer(file);
  388. const mimeType = getActualMimeTypeFromImage(buffer);
  389. if (mimeType && mimeType !== file.type) {
  390. file = createFile(buffer, mimeType, file.name);
  391. }
  392. }
  393. return file;
  394. };
  395. export const blobToArrayBuffer = (blob: Blob): Promise<ArrayBuffer> => {
  396. if ("arrayBuffer" in blob) {
  397. return blob.arrayBuffer();
  398. }
  399. // Safari
  400. return new Promise((resolve, reject) => {
  401. const reader = new FileReader();
  402. reader.onload = (event) => {
  403. if (!event.target?.result) {
  404. return reject(new Error("Couldn't convert blob to ArrayBuffer"));
  405. }
  406. resolve(event.target.result as ArrayBuffer);
  407. };
  408. reader.readAsArrayBuffer(blob);
  409. });
  410. };