diff --git a/packages/common/src/constants.ts b/packages/common/src/constants.ts index 23d5f426e6..03978584cb 100644 --- a/packages/common/src/constants.ts +++ b/packages/common/src/constants.ts @@ -251,6 +251,7 @@ export const STRING_MIME_TYPES = { json: "application/json", // excalidraw data excalidraw: "application/vnd.excalidraw+json", + excalidrawClipboard: "application/vnd.excalidraw.clipboard+json", // LEGACY: fully-qualified library JSON data excalidrawlib: "application/vnd.excalidrawlib+json", // list of excalidraw library item ids diff --git a/packages/excalidraw/clipboard.ts b/packages/excalidraw/clipboard.ts index 1e6d74eca3..55fa2b92c3 100644 --- a/packages/excalidraw/clipboard.ts +++ b/packages/excalidraw/clipboard.ts @@ -204,8 +204,13 @@ export const copyToClipboard = async ( /** supply if available to make the operation more certain to succeed */ clipboardEvent?: ClipboardEvent | null, ) => { + const json = serializeAsClipboardJSON({ elements, files }); + await copyTextToSystemClipboard( - serializeAsClipboardJSON({ elements, files }), + { + [MIME_TYPES.excalidrawClipboard]: json, + [MIME_TYPES.text]: json, + }, clipboardEvent, ); }; @@ -401,7 +406,7 @@ export type ParsedDataTransferFile = Extract< { kind: "file" } >; -type ParsedDataTranferList = ParsedDataTransferItem[] & { +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) @@ -452,6 +457,29 @@ const getDataTransferFiles = function ( ); }; +/** @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 => { @@ -460,8 +488,7 @@ export const parseDataTransferEvent = async ( if (isClipboardEvent(event)) { items = event.clipboardData?.items; } else { - const dragEvent = event; - items = dragEvent.dataTransfer?.items; + items = event.dataTransfer?.items; } const dataItems = ( @@ -567,7 +594,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | 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 window.ClipboardItem({ + new ClipboardItem({ [MIME_TYPES.png]: blob, }), ]); @@ -576,7 +603,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { // with resolution value instead if (isPromiseLike(blob)) { await navigator.clipboard.write([ - new window.ClipboardItem({ + new ClipboardItem({ [MIME_TYPES.png]: await blob, }), ]); @@ -586,37 +613,56 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { } }; -export const copyTextToSystemClipboard = async ( - text: string | null, +export const copyTextToSystemClipboard = async < + MimeType extends ValueOf, +>( + text: string | { [K in MimeType]: string } | null, clipboardEvent?: ClipboardEvent | null, ) => { - // (1) first try using Async Clipboard API - if (probablySupportsClipboardWriteText) { + 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); + } + + let 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(text || ""); - return; + await navigator.clipboard.writeText(plainTextEntry[1]); + + // invalidate it so we don't write it again below + plainTextEntry = undefined; } catch (error: any) { console.error(error); } } - // (2) if fails and we have access to ClipboardEvent, use plain old setData() - try { - if (clipboardEvent) { - clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || ""); - if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) { - throw new Error("Failed to setData on clipboardEvent"); - } - return; - } - } catch (error: any) { - console.error(error); - } - - // (3) if that fails, use document.execCommand - if (!copyTextViaExecCommand(text)) { + // (3) if previous fails, use document.execCommand + if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) { throw new Error("Error copying to clipboard."); } }; diff --git a/packages/excalidraw/wysiwyg/textWysiwyg.tsx b/packages/excalidraw/wysiwyg/textWysiwyg.tsx index 52c80e9c65..27086e07b9 100644 --- a/packages/excalidraw/wysiwyg/textWysiwyg.tsx +++ b/packages/excalidraw/wysiwyg/textWysiwyg.tsx @@ -13,6 +13,7 @@ import { } from "@excalidraw/common"; import { + getTextFromElements, originalContainerCache, updateBoundElements, updateOriginalContainerCache, @@ -49,7 +50,11 @@ import type { import { actionSaveToActiveFile } from "../actions"; -import { parseDataTransferEvent } from "../clipboard"; +import { + parseClipboard, + parseDataTransferEvent, + parseDataTransferEventMimeTypes, +} from "../clipboard"; import { actionDecreaseFontSize, actionIncreaseFontSize, @@ -60,6 +65,8 @@ import { actionZoomOut, } from "../actions/actionCanvas"; +import type { ParsedDataTranferList } from "../clipboard"; + import type App from "../components/App"; import type { AppState } from "../types"; @@ -328,9 +335,58 @@ export const textWysiwyg = ({ if (onChange) { editable.onpaste = async (event) => { - const textItem = (await parseDataTransferEvent(event)).findByType( - MIME_TYPES.text, - ); + // we need to synchronously get the MIME types so we can preventDefault() + // in the same tick (FF requires that) + const mimeTypes = parseDataTransferEventMimeTypes(event); + + let dataList: ParsedDataTranferList | null = null; + + // when copy/pasting excalidraw elements, only paste the text content + // + // Note that these custom MIME types only work within the same family + // of browsers, so won't work e.g. between chrome and firefox. We could + // parse the text/plain for existence of excalidraw instead, but this + // is an edge case + if ( + mimeTypes.has(MIME_TYPES.excalidrawClipboard) || + mimeTypes.has(MIME_TYPES.excalidraw) + ) { + // must be called in the same tick + event.preventDefault(); + + dataList = await parseDataTransferEvent(event); + + try { + const parsed = await parseClipboard(dataList); + + if (parsed.elements) { + const text = getTextFromElements(parsed.elements); + if (text) { + const { selectionStart, selectionEnd, value } = editable; + + editable.value = + value.slice(0, selectionStart) + + text + + value.slice(selectionEnd); + + const newPos = selectionStart + text.length; + editable.selectionStart = editable.selectionEnd = newPos; + + editable.dispatchEvent(new Event("input")); + } + } + + // if excalidraw elements don't contain any text elements, + // don't paste anything + return; + } catch { + console.warn("failed to parse excalidraw clipboard data"); + } + } + + dataList = dataList || (await parseDataTransferEvent(event)); + + const textItem = dataList.findByType(MIME_TYPES.text); if (!textItem) { return; }