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:
co-authored by
Ashutosh Kumar
parent
6a891365b9
commit
f5cf81ce42
@@ -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
|
||||||
|
|||||||
@@ -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.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user