import { ALLOWED_PASTE_MIME_TYPES, EXPORT_DATA_TYPES, MIME_TYPES, arrayToMap, isMemberOf, isPromiseLike, EVENT, } from "@excalidraw/common"; import { mutateElement } from "@excalidraw/element"; import { deepCopyElement } from "@excalidraw/element"; import { isFrameLikeElement, isInitializedImageElement, } from "@excalidraw/element"; import { getContainingFrame } from "@excalidraw/element"; import type { ValueOf } from "@excalidraw/common/utility-types"; import type { IMAGE_MIME_TYPES, STRING_MIME_TYPES } from "@excalidraw/common"; import type { ExcalidrawElement, NonDeletedExcalidrawElement, } from "@excalidraw/element/types"; import { ExcalidrawError } from "./errors"; import { createFile, getFileHandle, isSupportedImageFileType, normalizeFile, } from "./data/blob"; import type { BinaryFiles } from "./types"; type ElementsClipboard = { type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | undefined; }; export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[]; export interface ClipboardData { elements?: readonly ExcalidrawElement[]; files?: BinaryFiles; text?: string; mixedContent?: PastedMixedContent; errorMessage?: string; programmaticAPI?: boolean; } type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number]; type ParsedClipboardEventTextData = | { type: "text"; value: string } | { type: "mixedContent"; value: PastedMixedContent }; export const probablySupportsClipboardReadText = "clipboard" in navigator && "readText" in navigator.clipboard; export const probablySupportsClipboardWriteText = "clipboard" in navigator && "writeText" in navigator.clipboard; export const probablySupportsClipboardBlob = "clipboard" in navigator && "write" in navigator.clipboard && "ClipboardItem" in window && "toBlob" in HTMLCanvasElement.prototype; const clipboardContainsElements = ( contents: any, ): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => { if ( [ EXPORT_DATA_TYPES.excalidraw, EXPORT_DATA_TYPES.excalidrawClipboard, EXPORT_DATA_TYPES.excalidrawClipboardWithAPI, ].includes(contents?.type) && Array.isArray(contents.elements) ) { return true; } return false; }; export const createPasteEvent = ({ types, files, }: { types?: { [key in AllowedPasteMimeTypes]?: string | File }; files?: File[]; }) => { if (!types && !files) { console.warn("createPasteEvent: no types or files provided"); } const event = new ClipboardEvent(EVENT.PASTE, { clipboardData: new DataTransfer(), }); if (types) { for (const [type, value] of Object.entries(types)) { if (typeof value !== "string") { files = files || []; files.push(value); continue; } try { event.clipboardData?.items.add(value, type); if (event.clipboardData?.getData(type) !== value) { throw new Error(`Failed to set "${type}" as clipboardData item`); } } catch (error: any) { throw new Error(error.message); } } } if (files) { let idx = -1; for (const file of files) { idx++; try { event.clipboardData?.items.add(file); if (event.clipboardData?.files[idx] !== file) { throw new Error( `Failed to set file "${file.name}" as clipboardData item`, ); } } catch (error: any) { throw new Error(error.message); } } } return event; }; export const serializeAsClipboardJSON = ({ elements, files, }: { elements: readonly NonDeletedExcalidrawElement[]; files: BinaryFiles | null; }) => { const elementsMap = arrayToMap(elements); const framesToCopy = new Set( elements.filter((element) => isFrameLikeElement(element)), ); let foundFile = false; const _files = elements.reduce((acc, element) => { if (isInitializedImageElement(element)) { foundFile = true; if (files && files[element.fileId]) { acc[element.fileId] = files[element.fileId]; } } return acc; }, {} as BinaryFiles); if (foundFile && !files) { console.warn( "copyToClipboard: attempting to file element(s) without providing associated `files` object.", ); } // select bound text elements when copying const contents: ElementsClipboard = { type: EXPORT_DATA_TYPES.excalidrawClipboard, elements: elements.map((element) => { if ( getContainingFrame(element, elementsMap) && !framesToCopy.has(getContainingFrame(element, elementsMap)!) ) { const copiedElement = deepCopyElement(element); mutateElement(copiedElement, elementsMap, { frameId: null, }); return copiedElement; } return element; }), files: files ? _files : undefined, }; return JSON.stringify(contents); }; export const copyToClipboard = async ( elements: readonly NonDeletedExcalidrawElement[], files: BinaryFiles | null, /** supply if available to make the operation more certain to succeed */ clipboardEvent?: ClipboardEvent | null, ) => { const json = serializeAsClipboardJSON({ elements, files }); await copyTextToSystemClipboard( { [MIME_TYPES.excalidrawClipboard]: json, [MIME_TYPES.text]: json, }, clipboardEvent, ); }; /** internal, specific to parsing paste events. Do not reuse. */ function parseHTMLTree(el: ChildNode) { let result: PastedMixedContent = []; for (const node of el.childNodes) { if (node.nodeType === 3) { const text = node.textContent?.trim(); if (text) { result.push({ type: "text", value: text }); } } else if (node instanceof HTMLImageElement) { const url = node.getAttribute("src"); if (url && url.startsWith("http")) { result.push({ type: "imageUrl", value: url }); } } else { result = result.concat(parseHTMLTree(node)); } } return result; } const maybeParseHTMLDataItem = ( dataItem: ParsedDataTransferItemType, ): { type: "mixedContent"; value: PastedMixedContent } | null => { const html = dataItem.value; try { const doc = new DOMParser().parseFromString(html, MIME_TYPES.html); const content = parseHTMLTree(doc.body); if (content.length) { return { type: "mixedContent", value: content }; } } catch (error: any) { console.error(`error in parseHTMLFromPaste: ${error.message}`); } return null; }; /** * Reads OS clipboard programmatically. May not work on all browsers. * Will prompt user for permission if not granted. */ export const readSystemClipboard = async () => { const types: { [key in AllowedPasteMimeTypes]?: string | File } = {}; let clipboardItems: ClipboardItems; try { clipboardItems = await navigator.clipboard?.read(); } catch (error: any) { try { if (navigator.clipboard?.readText) { console.warn( `navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`, ); const readText = await navigator.clipboard?.readText(); if (readText) { return { [MIME_TYPES.text]: readText }; } } } catch (error: any) { // @ts-ignore if (navigator.clipboard?.read) { console.warn( `navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`, ); } else { if (error.name === "DataError") { console.warn( `navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`, ); return types; } throw error; } } throw error; } for (const item of clipboardItems) { for (const type of item.types) { if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) { continue; } try { if (type === MIME_TYPES.text || type === MIME_TYPES.html) { types[type] = await (await item.getType(type)).text(); } else if (isSupportedImageFileType(type)) { const imageBlob = await item.getType(type); const file = createFile(imageBlob, type, undefined); types[type] = file; } else { throw new ExcalidrawError(`Unsupported clipboard type: ${type}`); } } catch (error: any) { console.warn( error instanceof ExcalidrawError ? error.message : `Cannot retrieve ${type} from clipboardItem: ${error.message}`, ); } } } if (Object.keys(types).length === 0) { console.warn("No clipboard data found from clipboard.read()."); return types; } return types; }; /** * Parses "paste" ClipboardEvent. */ const parseClipboardEventTextData = async ( dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { try { const htmlItem = dataList.findByType(MIME_TYPES.html); const mixedContent = !isPlainPaste && htmlItem && maybeParseHTMLDataItem(htmlItem); if (mixedContent) { if (mixedContent.value.every((item) => item.type === "text")) { return { type: "text", value: dataList.getData(MIME_TYPES.text) ?? mixedContent.value .map((item) => item.value) .join("\n") .trim(), }; } return mixedContent; } return { type: "text", value: (dataList.getData(MIME_TYPES.text) || "").trim(), }; } catch { return { type: "text", value: "" }; } }; type AllowedParsedDataTransferItem = | { type: ValueOf; kind: "file"; file: File; fileHandle: FileSystemFileHandle | null; } | { type: ValueOf; kind: "string"; value: string }; type ParsedDataTransferItem = | { type: string; kind: "file"; file: File; fileHandle: FileSystemFileHandle | null; } | { type: string; kind: "string"; value: string }; type ParsedDataTransferItemType< T extends AllowedParsedDataTransferItem["type"], > = AllowedParsedDataTransferItem & { type: T }; export type ParsedDataTransferFile = Extract< AllowedParsedDataTransferItem, { kind: "file" } >; export type ParsedDataTranferList = ParsedDataTransferItem[] & { /** * Only allows filtering by known `string` data types, since `file` * types can have multiple items of the same type (e.g. multiple image files) * unlike `string` data transfer items. */ findByType: typeof findDataTransferItemType; /** * Only allows filtering by known `string` data types, since `file` * types can have multiple items of the same type (e.g. multiple image files) * unlike `string` data transfer items. */ getData: typeof getDataTransferItemData; getFiles: typeof getDataTransferFiles; }; const findDataTransferItemType = function < T extends ValueOf, >(this: ParsedDataTranferList, type: T): ParsedDataTransferItemType | null { return ( this.find( (item): item is ParsedDataTransferItemType => item.type === type, ) || null ); }; const getDataTransferItemData = function < T extends ValueOf, >( this: ParsedDataTranferList, type: T, ): | ParsedDataTransferItemType>["value"] | null { const item = this.find( ( item, ): item is ParsedDataTransferItemType> => item.type === type, ); return item?.value ?? null; }; const getDataTransferFiles = function ( this: ParsedDataTranferList, ): ParsedDataTransferFile[] { return this.filter( (item): item is ParsedDataTransferFile => item.kind === "file", ); }; /** @returns list of MIME types, synchronously */ export const parseDataTransferEventMimeTypes = ( event: ClipboardEvent | DragEvent | React.DragEvent, ): Set => { let items: DataTransferItemList | undefined = undefined; if (isClipboardEvent(event)) { items = event.clipboardData?.items; } else { items = event.dataTransfer?.items; } const types: Set = new Set(); for (const item of Array.from(items || [])) { if (!types.has(item.type)) { types.add(item.type); } } return types; }; export const parseDataTransferEvent = async ( event: ClipboardEvent | DragEvent | React.DragEvent, ): Promise => { let items: DataTransferItemList | undefined = undefined; if (isClipboardEvent(event)) { items = event.clipboardData?.items; } else { items = event.dataTransfer?.items; } const dataItems = ( await Promise.all( Array.from(items || []).map( async (item): Promise => { if (item.kind === "file") { let file = item.getAsFile(); if (file) { const fileHandle = await getFileHandle(item); file = await normalizeFile(file); return { type: file.type, kind: "file", file, fileHandle, }; } } else if (item.kind === "string") { const { type } = item; let value: string; if ("clipboardData" in event && event.clipboardData) { value = event.clipboardData?.getData(type); } else { value = await new Promise((resolve) => { item.getAsString((str) => resolve(str)); }); } return { type, kind: "string", value }; } return null; }, ), ) ).filter((data): data is ParsedDataTransferItem => data != null); return Object.assign(dataItems, { findByType: findDataTransferItemType, getData: getDataTransferItemData, getFiles: getDataTransferFiles, }); }; /** * Attempts to parse clipboard event. */ export const parseClipboard = async ( dataList: ParsedDataTranferList, isPlainPaste = false, ): Promise => { const parsedEventData = await parseClipboardEventTextData( dataList, isPlainPaste, ); if (parsedEventData.type === "mixedContent") { return { mixedContent: parsedEventData.value, }; } try { const systemClipboardData = JSON.parse(parsedEventData.value); const programmaticAPI = systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI; if (clipboardContainsElements(systemClipboardData)) { return { elements: systemClipboardData.elements, files: systemClipboardData.files, text: isPlainPaste ? JSON.stringify(systemClipboardData.elements, null, 2) : undefined, programmaticAPI, }; } } catch {} return { text: parsedEventData.value }; }; export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { try { // in Safari so far we need to construct the ClipboardItem synchronously // (i.e. in the same tick) otherwise browser will complain for lack of // user intent. Using a Promise ClipboardItem constructor solves this. // https://bugs.webkit.org/show_bug.cgi?id=222262 // // Note that Firefox (and potentially others) seems to support Promise // ClipboardItem constructor, but throws on an unrelated MIME type error. // So we need to await this and fallback to awaiting the blob if applicable. await navigator.clipboard.write([ new ClipboardItem({ [MIME_TYPES.png]: blob, }), ]); } catch (error: any) { // if we're using a Promise ClipboardItem, let's try constructing // with resolution value instead if (isPromiseLike(blob)) { await navigator.clipboard.write([ new ClipboardItem({ [MIME_TYPES.png]: await blob, }), ]); } else { throw error; } } }; export const copyTextToSystemClipboard = async < MimeType extends ValueOf, >( text: string | { [K in MimeType]: string } | null, clipboardEvent?: ClipboardEvent | null, ) => { text = text || ""; const entries = Object.entries( typeof text === "string" ? { [MIME_TYPES.text]: text } : text, ); // (1) if we have clipboardEvent, try using it first as it's the most // versatile try { if (clipboardEvent) { for (const [mimeType, value] of entries) { clipboardEvent.clipboardData?.setData(mimeType, value); if (clipboardEvent.clipboardData?.getData(mimeType) !== value) { throw new Error("Failed to setData on clipboardEvent"); } } return; } } catch (error: any) { console.error(error); } const plainTextEntry = entries.find( ([mimeType]) => mimeType === MIME_TYPES.text, ); // (2) if we don't have access to clipboardEvent, or that fails, // at least try setting text/plain via navigator.clipboard.writeText // (navigator.clipboard.write doesn't work with non-standard mime types) if (probablySupportsClipboardWriteText && plainTextEntry) { try { // NOTE: doesn't work on FF on non-HTTPS domains, or when document // not focused await navigator.clipboard.writeText(plainTextEntry[1]); return; } catch (error: any) { console.error(error); } } // (3) if previous fails, use document.execCommand if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) { throw new Error("Error copying to clipboard."); } }; // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48 const copyTextViaExecCommand = (text: string | null) => { // execCommand doesn't allow copying empty strings, so if we're // clearing clipboard using this API, we must copy at least an empty char if (!text) { text = " "; } const isRTL = document.documentElement.getAttribute("dir") === "rtl"; const textarea = document.createElement("textarea"); textarea.style.border = "0"; textarea.style.padding = "0"; textarea.style.margin = "0"; textarea.style.position = "absolute"; textarea.style[isRTL ? "right" : "left"] = "-9999px"; const yPosition = window.pageYOffset || document.documentElement.scrollTop; textarea.style.top = `${yPosition}px`; // Prevent zooming on iOS textarea.style.fontSize = "12pt"; textarea.setAttribute("readonly", ""); textarea.value = text; document.body.appendChild(textarea); let success = false; try { textarea.select(); textarea.setSelectionRange(0, textarea.value.length); success = document.execCommand("copy"); } catch (error: any) { console.error(error); } textarea.remove(); return success; }; export const isClipboardEvent = ( event: React.SyntheticEvent | Event, ): event is ClipboardEvent => { /** not using instanceof ClipboardEvent due to tests (jsdom) */ return ( event.type === EVENT.PASTE || event.type === EVENT.COPY || event.type === EVENT.CUT ); };