diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 007a02161b..ae532a6c27 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -470,13 +470,14 @@ export const parseDataTransferEvent = async ( Array.from(items || []).map( async (item): Promise => { if (item.kind === "file") { - const file = item.getAsFile(); + let file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); + file = await normalizeFile(file); return { type: file.type, kind: "file", - file: await normalizeFile(file), + file, fileHandle, }; } diff --git a/packages/excalidraw/data/blob.ts b/packages/excalidraw/data/blob.ts index 2b6829a938..27bafaf386 100644 --- a/packages/excalidraw/data/blob.ts +++ b/packages/excalidraw/data/blob.ts @@ -25,7 +25,7 @@ import { restore, restoreLibraryItems } from "./restore"; import type { AppState, DataURL, LibraryItem } from "../types"; -import type { FileSystemHandle } from "./filesystem"; +import type { FileSystemHandle } from "browser-fs-access"; import type { ImportedLibraryData } from "./types"; const parseFileContents = async (blob: Blob | File): Promise => { @@ -417,13 +417,14 @@ export const getFileHandle = async ( * attempts to detect if a buffer is a valid image by checking its leading bytes */ const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { - let mimeType: ValueOf> | null = - null; + let mimeType: ValueOf< + Pick + > | null = null; - const first8Bytes = `${[...new Uint8Array(buffer).slice(0, 8)].join(" ")} `; + const leadingBytes = `${[...new Uint8Array(buffer).slice(0, 15)].join(" ")} `; // uint8 leading bytes - const headerBytes = { + const bytes = { // 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 @@ -432,14 +433,18 @@ const getActualMimeTypeFromImage = (buffer: ArrayBuffer) => { jpg: "255 216 255 ", // https://en.wikipedia.org/wiki/GIF#Example_GIF_file gif: "71 73 70 56 57 97 ", + // 4 bytes for RIFF + 4 bytes for chunk size + WEBP identifier + webp: /^82 73 70 70 \d+ \d+ \d+ \d+ 87 69 66 80 86 80 56/, }; - if (first8Bytes === headerBytes.png) { + if (leadingBytes === bytes.png) { mimeType = MIME_TYPES.png; - } else if (first8Bytes.startsWith(headerBytes.jpg)) { + } else if (leadingBytes.startsWith(bytes.jpg)) { mimeType = MIME_TYPES.jpg; - } else if (first8Bytes.startsWith(headerBytes.gif)) { + } else if (leadingBytes.startsWith(bytes.gif)) { mimeType = MIME_TYPES.gif; + } else if (bytes.webp.test(leadingBytes)) { + mimeType = MIME_TYPES.webp; } return mimeType; }; @@ -454,40 +459,33 @@ export const createFile = ( }); }; +const normalizedFileSymbol = Symbol("fileNormalized"); + /** 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); - } - } + // to prevent double normalization (perf optim) + if ((file as any)[normalizedFileSymbol]) { + return file; + } + + if (file?.name?.endsWith(".excalidrawlib")) { + file = createFile(file, MIME_TYPES.excalidrawlib, file.name); + } else if (file?.name?.endsWith(".excalidraw")) { + file = createFile(file, MIME_TYPES.excalidraw, file.name); + } else if (!file.type || file.type?.startsWith("image/")) { // 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); + // actual mimeType (this is an edge case, but happens - especially + // with AI generated images) + const mimeType = getActualMimeTypeFromImage(await blobToArrayBuffer(file)); if (mimeType && mimeType !== file.type) { - file = createFile(buffer, mimeType, file.name); + file = createFile(file, mimeType, file.name); } } + (file as any)[normalizedFileSymbol] = true; + return file; }; diff --git a/packages/excalidraw/data/filesystem.ts b/packages/excalidraw/data/filesystem.ts index 0f4ae745f9..44474a6f61 100644 --- a/packages/excalidraw/data/filesystem.ts +++ b/packages/excalidraw/data/filesystem.ts @@ -8,13 +8,15 @@ import { EVENT, MIME_TYPES, debounce } from "@excalidraw/common"; import { AbortError } from "../errors"; +import { normalizeFile } from "./blob"; + import type { FileSystemHandle } from "browser-fs-access"; type FILE_EXTENSION = Exclude; const INPUT_CHANGE_INTERVAL_MS = 500; -export const fileOpen = (opts: { +export const fileOpen = async (opts: { extensions?: FILE_EXTENSION[]; description: string; multiple?: M; @@ -35,7 +37,7 @@ export const fileOpen = (opts: { return acc.concat(`.${ext}`); }, [] as string[]); - return _fileOpen({ + const files = await _fileOpen({ description: opts.description, extensions, mimeTypes, @@ -74,7 +76,14 @@ export const fileOpen = (opts: { } }; }, - }) as Promise; + }); + + if (Array.isArray(files)) { + return (await Promise.all( + files.map((file) => normalizeFile(file)), + )) as RetType; + } + return (await normalizeFile(files)) as RetType; }; export const fileSave = ( diff --git a/packages/excalidraw/data/json.ts b/packages/excalidraw/data/json.ts index 52cbf99581..da07ba25fe 100644 --- a/packages/excalidraw/data/json.ts +++ b/packages/excalidraw/data/json.ts @@ -108,12 +108,7 @@ export const loadFromJSON = async ( // gets resolved. Else, iOS users cannot open `.excalidraw` files. // extensions: ["json", "excalidraw", "png", "svg"], }); - return loadFromBlob( - await normalizeFile(file), - localAppState, - localElements, - file.handle, - ); + return loadFromBlob(file, localAppState, localElements, file.handle); }; export const isValidExcalidrawData = (data?: {