feat: prevent pasting excalidraw into textarea & paste element text if avail (#10710)

* feat: prevent pasting excalidraw json into textarea & paste element text if avail

Co-authored-by: Ashutosh Kumar <130897584+codeaashu@users.noreply.github.com>

* fix FF

---------

Co-authored-by: Ashutosh Kumar <130897584+codeaashu@users.noreply.github.com>
This commit is contained in:
David Luzar
2026-01-28 22:35:39 +01:00
committed by GitHub
co-authored by Ashutosh Kumar
parent 6a891365b9
commit f5cf81ce42
3 changed files with 134 additions and 31 deletions
+1
View File
@@ -251,6 +251,7 @@ export const STRING_MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data
excalidraw: "application/vnd.excalidraw+json", excalidraw: "application/vnd.excalidraw+json",
excalidrawClipboard: "application/vnd.excalidraw.clipboard+json",
// LEGACY: fully-qualified library JSON data // LEGACY: fully-qualified library JSON data
excalidrawlib: "application/vnd.excalidrawlib+json", excalidrawlib: "application/vnd.excalidrawlib+json",
// list of excalidraw library item ids // list of excalidraw library item ids
+73 -27
View File
@@ -204,8 +204,13 @@ export const copyToClipboard = async (
/** supply if available to make the operation more certain to succeed */ /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
const json = serializeAsClipboardJSON({ elements, files });
await copyTextToSystemClipboard( await copyTextToSystemClipboard(
serializeAsClipboardJSON({ elements, files }), {
[MIME_TYPES.excalidrawClipboard]: json,
[MIME_TYPES.text]: json,
},
clipboardEvent, clipboardEvent,
); );
}; };
@@ -401,7 +406,7 @@ export type ParsedDataTransferFile = Extract<
{ kind: "file" } { kind: "file" }
>; >;
type ParsedDataTranferList = ParsedDataTransferItem[] & { export type ParsedDataTranferList = ParsedDataTransferItem[] & {
/** /**
* Only allows filtering by known `string` data types, since `file` * Only allows filtering by known `string` data types, since `file`
* types can have multiple items of the same type (e.g. multiple image files) * 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<HTMLDivElement>,
): Set<string> => {
let items: DataTransferItemList | undefined = undefined;
if (isClipboardEvent(event)) {
items = event.clipboardData?.items;
} else {
items = event.dataTransfer?.items;
}
const types: Set<string> = new Set();
for (const item of Array.from(items || [])) {
if (!types.has(item.type)) {
types.add(item.type);
}
}
return types;
};
export const parseDataTransferEvent = async ( export const parseDataTransferEvent = async (
event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>, event: ClipboardEvent | DragEvent | React.DragEvent<HTMLDivElement>,
): Promise<ParsedDataTranferList> => { ): Promise<ParsedDataTranferList> => {
@@ -460,8 +488,7 @@ export const parseDataTransferEvent = async (
if (isClipboardEvent(event)) { if (isClipboardEvent(event)) {
items = event.clipboardData?.items; items = event.clipboardData?.items;
} else { } else {
const dragEvent = event; items = event.dataTransfer?.items;
items = dragEvent.dataTransfer?.items;
} }
const dataItems = ( const dataItems = (
@@ -567,7 +594,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// ClipboardItem constructor, but throws on an unrelated MIME type error. // ClipboardItem constructor, but throws on an unrelated MIME type error.
// So we need to await this and fallback to awaiting the blob if applicable. // So we need to await this and fallback to awaiting the blob if applicable.
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ new ClipboardItem({
[MIME_TYPES.png]: blob, [MIME_TYPES.png]: blob,
}), }),
]); ]);
@@ -576,7 +603,7 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
// with resolution value instead // with resolution value instead
if (isPromiseLike(blob)) { if (isPromiseLike(blob)) {
await navigator.clipboard.write([ await navigator.clipboard.write([
new window.ClipboardItem({ new ClipboardItem({
[MIME_TYPES.png]: await blob, [MIME_TYPES.png]: await blob,
}), }),
]); ]);
@@ -586,37 +613,56 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async ( export const copyTextToSystemClipboard = async <
text: string | null, MimeType extends ValueOf<typeof STRING_MIME_TYPES>,
>(
text: string | { [K in MimeType]: string } | null,
clipboardEvent?: ClipboardEvent | null, clipboardEvent?: ClipboardEvent | null,
) => { ) => {
// (1) first try using Async Clipboard API text = text || "";
if (probablySupportsClipboardWriteText) {
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 { try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document // NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused // not focused
await navigator.clipboard.writeText(text || ""); await navigator.clipboard.writeText(plainTextEntry[1]);
return;
// invalidate it so we don't write it again below
plainTextEntry = undefined;
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
} }
// (2) if fails and we have access to ClipboardEvent, use plain old setData() // (3) if previous fails, use document.execCommand
try { if (plainTextEntry && !copyTextViaExecCommand(plainTextEntry[1])) {
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)) {
throw new Error("Error copying to clipboard."); throw new Error("Error copying to clipboard.");
} }
}; };
+60 -4
View File
@@ -13,6 +13,7 @@ import {
} from "@excalidraw/common"; } from "@excalidraw/common";
import { import {
getTextFromElements,
originalContainerCache, originalContainerCache,
updateBoundElements, updateBoundElements,
updateOriginalContainerCache, updateOriginalContainerCache,
@@ -49,7 +50,11 @@ import type {
import { actionSaveToActiveFile } from "../actions"; import { actionSaveToActiveFile } from "../actions";
import { parseDataTransferEvent } from "../clipboard"; import {
parseClipboard,
parseDataTransferEvent,
parseDataTransferEventMimeTypes,
} from "../clipboard";
import { import {
actionDecreaseFontSize, actionDecreaseFontSize,
actionIncreaseFontSize, actionIncreaseFontSize,
@@ -60,6 +65,8 @@ import {
actionZoomOut, actionZoomOut,
} from "../actions/actionCanvas"; } from "../actions/actionCanvas";
import type { ParsedDataTranferList } from "../clipboard";
import type App from "../components/App"; import type App from "../components/App";
import type { AppState } from "../types"; import type { AppState } from "../types";
@@ -328,9 +335,58 @@ export const textWysiwyg = ({
if (onChange) { if (onChange) {
editable.onpaste = async (event) => { editable.onpaste = async (event) => {
const textItem = (await parseDataTransferEvent(event)).findByType( // we need to synchronously get the MIME types so we can preventDefault()
MIME_TYPES.text, // 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) { if (!textItem) {
return; return;
} }