feat: make clipboard more robust and reintroduce contextmenu actions (#7198)

This commit is contained in:
David Luzar 2023-10-28 21:29:28 +02:00 committed by GitHub
parent ec2de7205f
commit ea677d4581
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 611 additions and 193 deletions

View File

@ -3,33 +3,43 @@ import { register } from "./register";
import { import {
copyTextToSystemClipboard, copyTextToSystemClipboard,
copyToClipboard, copyToClipboard,
createPasteEvent,
probablySupportsClipboardBlob, probablySupportsClipboardBlob,
probablySupportsClipboardWriteText, probablySupportsClipboardWriteText,
readSystemClipboard,
} from "../clipboard"; } from "../clipboard";
import { actionDeleteSelected } from "./actionDeleteSelected"; import { actionDeleteSelected } from "./actionDeleteSelected";
import { exportCanvas } from "../data/index"; import { exportCanvas } from "../data/index";
import { getNonDeletedElements, isTextElement } from "../element"; import { getNonDeletedElements, isTextElement } from "../element";
import { t } from "../i18n"; import { t } from "../i18n";
import { isFirefox } from "../constants";
export const actionCopy = register({ export const actionCopy = register({
name: "copy", name: "copy",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, _, app) => { perform: async (elements, appState, event: ClipboardEvent | null, app) => {
const elementsToCopy = app.scene.getSelectedElements({ const elementsToCopy = app.scene.getSelectedElements({
selectedElementIds: appState.selectedElementIds, selectedElementIds: appState.selectedElementIds,
includeBoundTextElement: true, includeBoundTextElement: true,
includeElementsInFrames: true, includeElementsInFrames: true,
}); });
copyToClipboard(elementsToCopy, app.files); try {
await copyToClipboard(elementsToCopy, app.files, event);
} catch (error: any) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: error.message,
},
};
}
return { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.copy", contextItemLabel: "labels.copy",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
@ -38,15 +48,55 @@ export const actionCopy = register({
export const actionPaste = register({ export const actionPaste = register({
name: "paste", name: "paste",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements: any, appStates: any, data, app) => { perform: async (elements, appState, data, app) => {
app.pasteFromClipboard(null); let types;
try {
types = await readSystemClipboard();
} catch (error: any) {
if (error.name === "AbortError" || error.name === "NotAllowedError") {
// user probably aborted the action. Though not 100% sure, it's best
// to not annoy them with an error message.
return false;
}
console.error(`actionPaste ${error.name}: ${error.message}`);
if (isFirefox) {
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("hints.firefox_clipboard_write"),
},
};
}
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnRead"),
},
};
}
try {
app.pasteFromClipboard(createPasteEvent({ types }));
} catch (error: any) {
console.error(error);
return {
commitToHistory: false,
appState: {
...appState,
errorMessage: t("errors.asyncPasteFailedOnParse"),
},
};
}
return { return {
commitToHistory: false, commitToHistory: false,
}; };
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.paste", contextItemLabel: "labels.paste",
// don't supply a shortcut since we handle this conditionally via onCopy event // don't supply a shortcut since we handle this conditionally via onCopy event
keyTest: undefined, keyTest: undefined,
@ -55,13 +105,10 @@ export const actionPaste = register({
export const actionCut = register({ export const actionCut = register({
name: "cut", name: "cut",
trackEvent: { category: "element" }, trackEvent: { category: "element" },
perform: (elements, appState, data, app) => { perform: (elements, appState, event: ClipboardEvent | null, app) => {
actionCopy.perform(elements, appState, data, app); actionCopy.perform(elements, appState, event, app);
return actionDeleteSelected.perform(elements, appState); return actionDeleteSelected.perform(elements, appState);
}, },
predicate: (elements, appState, appProps, app) => {
return app.device.isMobile && !!navigator.clipboard;
},
contextItemLabel: "labels.cut", contextItemLabel: "labels.cut",
keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X, keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
}); });

View File

@ -119,10 +119,10 @@ export class ActionManager {
return true; return true;
} }
executeAction( executeAction<T extends Action>(
action: Action, action: T,
source: ActionSource = "api", source: ActionSource = "api",
value: any = null, value: Parameters<T["perform"]>[2] = null,
) { ) {
const elements = this.getElementsIncludingDeleted(); const elements = this.getElementsIncludingDeleted();
const appState = this.getAppState(); const appState = this.getAppState();

View File

@ -1,22 +1,196 @@
import { parseClipboard } from "./clipboard"; import {
import { createPasteEvent } from "./tests/test-utils"; createPasteEvent,
parseClipboard,
serializeAsClipboardJSON,
} from "./clipboard";
import { API } from "./tests/helpers/api";
describe("Test parseClipboard", () => { describe("parseClipboard()", () => {
it("should parse valid json correctly", async () => { it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
let text = "123"; let text;
let clipboardData;
// -------------------------------------------------------------------------
let clipboardData = await parseClipboard( text = "123";
createPasteEvent({ "text/plain": text }), clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
); );
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = "[123]"; text = "[123]";
clipboardData = await parseClipboard( clipboardData = await parseClipboard(
createPasteEvent({ "text/plain": text }), createPasteEvent({ types: { "text/plain": text } }),
); );
expect(clipboardData.text).toBe(text);
// -------------------------------------------------------------------------
text = JSON.stringify({ val: 42 });
clipboardData = await parseClipboard(
createPasteEvent({ types: { "text/plain": text } }),
);
expect(clipboardData.text).toBe(text); expect(clipboardData.text).toBe(text);
}); });
it("should parse valid excalidraw JSON if inside text/plain", async () => {
const rect = API.createElement({ type: "rectangle" });
const json = serializeAsClipboardJSON({ elements: [rect], files: null });
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
});
it("should parse valid excalidraw JSON if inside text/html", async () => {
const rect = API.createElement({ type: "rectangle" });
let json;
let clipboardData;
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": json,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
json = serializeAsClipboardJSON({ elements: [rect], files: null });
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div> ${json}</div>`,
},
}),
);
expect(clipboardData.elements).toEqual([rect]);
// -------------------------------------------------------------------------
});
it("should parse <image> `src` urls out of text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<img src="https://example.com/image.png" />`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
]);
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<div><img src="https://example.com/image.png" /></div><a><img src="https://example.com/image2.png" /></a>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "imageUrl",
value: "https://example.com/image2.png",
},
]);
});
it("should parse text content alongside <image> `src` urls out of text/html", async () => {
const clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<a href="https://example.com">hello </a><div><img src="https://example.com/image.png" /></div><b>my friend!</b>`,
},
}),
);
expect(clipboardData.mixedContent).toEqual([
{
type: "text",
// trimmed
value: "hello",
},
{
type: "imageUrl",
value: "https://example.com/image.png",
},
{
type: "text",
value: "my friend!",
},
]);
});
it("should parse spreadsheet from either text/plain and text/html", async () => {
let clipboardData;
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
// -------------------------------------------------------------------------
clipboardData = await parseClipboard(
createPasteEvent({
types: {
"text/html": `<html>
<body>
<!--StartFragment--><google-sheets-html-origin><style type="text/css"><!--td {border: 1px solid #cccccc;}br {mso-data-placement:same-cell;}--></style><table xmlns="http://www.w3.org/1999/xhtml" cellspacing="0" cellpadding="0" dir="ltr" border="1" style="table-layout:fixed;font-size:10pt;font-family:Arial;width:0px;border-collapse:collapse;border:none"><colgroup><col width="100"/><col width="100"/></colgroup><tbody><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;a&quot;}">a</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;" data-sheets-value="{&quot;1&quot;:2,&quot;2&quot;:&quot;b&quot;}">b</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:1}">1</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:2}">2</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:4}">4</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:5}">5</td></tr><tr style="height:21px;"><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:7}">7</td><td style="overflow:hidden;padding:2px 3px 2px 3px;vertical-align:bottom;text-align:right;" data-sheets-value="{&quot;1&quot;:3,&quot;3&quot;:10}">10</td></tr></tbody></table><!--EndFragment-->
</body>
</html>`,
"text/plain": `a b
1 2
4 5
7 10`,
},
}),
);
expect(clipboardData.spreadsheet).toEqual({
title: "b",
labels: ["1", "4", "7"],
values: [2, 5, 10],
});
});
}); });

View File

@ -3,14 +3,18 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
} from "./element/types"; } from "./element/types";
import { BinaryFiles } from "./types"; import { BinaryFiles } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts"; import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants"; import {
ALLOWED_PASTE_MIME_TYPES,
EXPORT_DATA_TYPES,
MIME_TYPES,
} from "./constants";
import { isInitializedImageElement } from "./element/typeChecks"; import { isInitializedImageElement } from "./element/typeChecks";
import { deepCopyElement } from "./element/newElement"; import { deepCopyElement } from "./element/newElement";
import { mutateElement } from "./element/mutateElement"; import { mutateElement } from "./element/mutateElement";
import { getContainingFrame } from "./frame"; import { getContainingFrame } from "./frame";
import { isPromiseLike, isTestEnv } from "./utils"; import { isMemberOf, isPromiseLike } from "./utils";
import { t } from "./i18n";
type ElementsClipboard = { type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard; type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@ -30,8 +34,11 @@ export interface ClipboardData {
programmaticAPI?: boolean; programmaticAPI?: boolean;
} }
let CLIPBOARD = ""; type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
let PREFER_APP_CLIPBOARD = false;
type ParsedClipboardEvent =
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent };
export const probablySupportsClipboardReadText = export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard; "clipboard" in navigator && "readText" in navigator.clipboard;
@ -61,10 +68,61 @@ const clipboardContainsElements = (
return false; return false;
}; };
export const copyToClipboard = async ( export const createPasteEvent = ({
elements: readonly NonDeletedExcalidrawElement[], types,
files: BinaryFiles | null, files,
) => { }: {
types?: { [key in AllowedPasteMimeTypes]?: string };
files?: File[];
}) => {
if (!types && !files) {
console.warn("createPasteEvent: no types or files provided");
}
const event = new ClipboardEvent("paste", {
clipboardData: new DataTransfer(),
});
if (types) {
for (const [type, value] of Object.entries(types)) {
try {
event.clipboardData?.setData(type, value);
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 framesToCopy = new Set( const framesToCopy = new Set(
elements.filter((element) => element.type === "frame"), elements.filter((element) => element.type === "frame"),
); );
@ -86,7 +144,7 @@ export const copyToClipboard = async (
); );
} }
// select binded text elements when copying // select bound text elements when copying
const contents: ElementsClipboard = { const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard, type: EXPORT_DATA_TYPES.excalidrawClipboard,
elements: elements.map((element) => { elements: elements.map((element) => {
@ -105,34 +163,20 @@ export const copyToClipboard = async (
}), }),
files: files ? _files : undefined, files: files ? _files : undefined,
}; };
const json = JSON.stringify(contents);
if (isTestEnv()) { return JSON.stringify(contents);
return json;
}
CLIPBOARD = json;
try {
PREFER_APP_CLIPBOARD = false;
await copyTextToSystemClipboard(json);
} catch (error: any) {
PREFER_APP_CLIPBOARD = true;
console.error(error);
}
}; };
const getAppClipboard = (): Partial<ElementsClipboard> => { export const copyToClipboard = async (
if (!CLIPBOARD) { elements: readonly NonDeletedExcalidrawElement[],
return {}; files: BinaryFiles | null,
} /** supply if available to make the operation more certain to succeed */
clipboardEvent?: ClipboardEvent | null,
try { ) => {
return JSON.parse(CLIPBOARD); await copyTextToSystemClipboard(
} catch (error: any) { serializeAsClipboardJSON({ elements, files }),
console.error(error); clipboardEvent,
return {}; );
}
}; };
const parsePotentialSpreadsheet = ( const parsePotentialSpreadsheet = (
@ -166,7 +210,9 @@ function parseHTMLTree(el: ChildNode) {
return result; return result;
} }
const maybeParseHTMLPaste = (event: ClipboardEvent) => { const maybeParseHTMLPaste = (
event: ClipboardEvent,
): { type: "mixedContent"; value: PastedMixedContent } | null => {
const html = event.clipboardData?.getData("text/html"); const html = event.clipboardData?.getData("text/html");
if (!html) { if (!html) {
@ -179,7 +225,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
const content = parseHTMLTree(doc.body); const content = parseHTMLTree(doc.body);
if (content.length) { if (content.length) {
return content; return { type: "mixedContent", value: content };
} }
} catch (error: any) { } catch (error: any) {
console.error(`error in parseHTMLFromPaste: ${error.message}`); console.error(`error in parseHTMLFromPaste: ${error.message}`);
@ -188,27 +234,88 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
return null; return null;
}; };
export const readSystemClipboard = async () => {
const types: { [key in AllowedPasteMimeTypes]?: string } = {};
try {
if (navigator.clipboard?.readText) {
return { "text/plain": await navigator.clipboard?.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 {
throw error;
}
}
let clipboardItems: ClipboardItems;
try {
clipboardItems = await navigator.clipboard?.read();
} catch (error: any) {
if (error.name === "DataError") {
console.warn(
`navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
);
return types;
}
throw error;
}
for (const item of clipboardItems) {
for (const type of item.types) {
if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
continue;
}
try {
types[type] = await (await item.getType(type)).text();
} catch (error: any) {
console.warn(
`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;
};
/** /**
* Retrieves content from system clipboard (either from ClipboardEvent or * Parses "paste" ClipboardEvent.
* via async clipboard API if supported)
*/ */
const getSystemClipboard = async ( const parseClipboardEvent = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise< ): Promise<ParsedClipboardEvent> => {
| { type: "text"; value: string }
| { type: "mixedContent"; value: PastedMixedContent }
> => {
try { try {
const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event); const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
if (mixedContent) { if (mixedContent) {
return { type: "mixedContent", value: mixedContent }; if (mixedContent.value.every((item) => item.type === "text")) {
return {
type: "text",
value:
event.clipboardData?.getData("text/plain") ||
mixedContent.value
.map((item) => item.value)
.join("\n")
.trim(),
};
}
return mixedContent;
} }
const text = event const text = event.clipboardData?.getData("text/plain");
? event.clipboardData?.getData("text/plain")
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
return { type: "text", value: (text || "").trim() }; return { type: "text", value: (text || "").trim() };
} catch { } catch {
@ -220,40 +327,32 @@ const getSystemClipboard = async (
* Attempts to parse clipboard. Prefers system clipboard. * Attempts to parse clipboard. Prefers system clipboard.
*/ */
export const parseClipboard = async ( export const parseClipboard = async (
event: ClipboardEvent | null, event: ClipboardEvent,
isPlainPaste = false, isPlainPaste = false,
): Promise<ClipboardData> => { ): Promise<ClipboardData> => {
const systemClipboard = await getSystemClipboard(event, isPlainPaste); const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
if (systemClipboard.type === "mixedContent") { if (parsedEventData.type === "mixedContent") {
return { return {
mixedContent: systemClipboard.value, mixedContent: parsedEventData.value,
}; };
} }
// if system clipboard empty, couldn't be resolved, or contains previously try {
// copied excalidraw scene as SVG, fall back to previously copied excalidraw // if system clipboard contains spreadsheet, use it even though it's
// elements // technically possible it's staler than in-app clipboard
if ( const spreadsheetResult =
!systemClipboard || !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
(!isPlainPaste && systemClipboard.value.includes(SVG_EXPORT_TAG))
) { if (spreadsheetResult) {
return getAppClipboard(); return spreadsheetResult;
}
} catch (error: any) {
console.error(error);
} }
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
const spreadsheetResult =
!isPlainPaste && parsePotentialSpreadsheet(systemClipboard.value);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try { try {
const systemClipboardData = JSON.parse(systemClipboard.value); const systemClipboardData = JSON.parse(parsedEventData.value);
const programmaticAPI = const programmaticAPI =
systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI; systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
if (clipboardContainsElements(systemClipboardData)) { if (clipboardContainsElements(systemClipboardData)) {
@ -266,18 +365,9 @@ export const parseClipboard = async (
programmaticAPI, programmaticAPI,
}; };
} }
} catch (e) {} } catch {}
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't return { text: parsedEventData.value };
// support storing to system clipboard on copy
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? {
...appClipboardData,
text: isPlainPaste
? JSON.stringify(appClipboardData.elements, null, 2)
: undefined,
}
: { text: systemClipboard.value };
}; };
export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => { export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
@ -310,28 +400,49 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
} }
}; };
export const copyTextToSystemClipboard = async (text: string | null) => { export const copyTextToSystemClipboard = async (
let copied = false; text: string | null,
clipboardEvent?: ClipboardEvent | null,
) => {
// (1) first try using Async Clipboard API
if (probablySupportsClipboardWriteText) { if (probablySupportsClipboardWriteText) {
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(text || "");
copied = true; return;
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
} }
} }
// Note that execCommand doesn't allow copying empty strings, so if we're // (2) if fails and we have access to ClipboardEvent, use plain old setData()
// clearing clipboard using this API, we must copy at least an empty char try {
if (!copied && !copyTextViaExecCommand(text || " ")) { if (clipboardEvent) {
throw new Error("couldn't copy"); clipboardEvent.clipboardData?.setData("text/plain", text || "");
if (clipboardEvent.clipboardData?.getData("text/plain") !== 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(t("errors.copyToSystemClipboardFailed"));
} }
}; };
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48 // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => { 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 isRTL = document.documentElement.getAttribute("dir") === "rtl";
const textarea = document.createElement("textarea"); const textarea = document.createElement("textarea");

View File

@ -1275,6 +1275,12 @@ class App extends React.Component<AppProps, AppState> {
top={this.state.contextMenu.top} top={this.state.contextMenu.top}
left={this.state.contextMenu.left} left={this.state.contextMenu.left}
actionManager={this.actionManager} actionManager={this.actionManager}
onClose={(callback) => {
this.setState({ contextMenu: null }, () => {
this.focusContainer();
callback?.();
});
}}
/> />
)} )}
<StaticCanvas <StaticCanvas
@ -2110,7 +2116,7 @@ class App extends React.Component<AppProps, AppState> {
if (!isExcalidrawActive || isWritableElement(event.target)) { if (!isExcalidrawActive || isWritableElement(event.target)) {
return; return;
} }
this.cutAll(); this.actionManager.executeAction(actionCut, "keyboard", event);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}); });
@ -2122,19 +2128,11 @@ class App extends React.Component<AppProps, AppState> {
if (!isExcalidrawActive || isWritableElement(event.target)) { if (!isExcalidrawActive || isWritableElement(event.target)) {
return; return;
} }
this.copyAll(); this.actionManager.executeAction(actionCopy, "keyboard", event);
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
}); });
private cutAll = () => {
this.actionManager.executeAction(actionCut, "keyboard");
};
private copyAll = () => {
this.actionManager.executeAction(actionCopy, "keyboard");
};
private static resetTapTwice() { private static resetTapTwice() {
didTapTwice = false; didTapTwice = false;
} }
@ -2195,8 +2193,8 @@ class App extends React.Component<AppProps, AppState> {
}; };
public pasteFromClipboard = withBatchedUpdates( public pasteFromClipboard = withBatchedUpdates(
async (event: ClipboardEvent | null) => { async (event: ClipboardEvent) => {
const isPlainPaste = !!(IS_PLAIN_PASTE && event); const isPlainPaste = !!IS_PLAIN_PASTE;
// #686 // #686
const target = document.activeElement; const target = document.activeElement;

View File

@ -9,11 +9,7 @@ import {
} from "../actions/shortcuts"; } from "../actions/shortcuts";
import { Action } from "../actions/types"; import { Action } from "../actions/types";
import { ActionManager } from "../actions/manager"; import { ActionManager } from "../actions/manager";
import { import { useExcalidrawAppState, useExcalidrawElements } from "./App";
useExcalidrawAppState,
useExcalidrawElements,
useExcalidrawSetAppState,
} from "./App";
import React from "react"; import React from "react";
export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action; export type ContextMenuItem = typeof CONTEXT_MENU_SEPARATOR | Action;
@ -25,14 +21,14 @@ type ContextMenuProps = {
items: ContextMenuItems; items: ContextMenuItems;
top: number; top: number;
left: number; left: number;
onClose: (callback?: () => void) => void;
}; };
export const CONTEXT_MENU_SEPARATOR = "separator"; export const CONTEXT_MENU_SEPARATOR = "separator";
export const ContextMenu = React.memo( export const ContextMenu = React.memo(
({ actionManager, items, top, left }: ContextMenuProps) => { ({ actionManager, items, top, left, onClose }: ContextMenuProps) => {
const appState = useExcalidrawAppState(); const appState = useExcalidrawAppState();
const setAppState = useExcalidrawSetAppState();
const elements = useExcalidrawElements(); const elements = useExcalidrawElements();
const filteredItems = items.reduce((acc: ContextMenuItem[], item) => { const filteredItems = items.reduce((acc: ContextMenuItem[], item) => {
@ -54,7 +50,9 @@ export const ContextMenu = React.memo(
return ( return (
<Popover <Popover
onCloseRequest={() => setAppState({ contextMenu: null })} onCloseRequest={() => {
onClose();
}}
top={top} top={top}
left={left} left={left}
fitInViewport={true} fitInViewport={true}
@ -102,7 +100,7 @@ export const ContextMenu = React.memo(
// we need update state before executing the action in case // we need update state before executing the action in case
// the action uses the appState it's being passed (that still // the action uses the appState it's being passed (that still
// contains a defined contextMenu) to return the next state. // contains a defined contextMenu) to return the next state.
setAppState({ contextMenu: null }, () => { onClose(() => {
actionManager.executeAction(item, "contextMenu"); actionManager.executeAction(item, "contextMenu");
}); });
}} }}

View File

@ -148,6 +148,8 @@ export const IMAGE_MIME_TYPES = {
jfif: "image/jfif", jfif: "image/jfif",
} as const; } as const;
export const ALLOWED_PASTE_MIME_TYPES = ["text/plain", "text/html"] as const;
export const MIME_TYPES = { export const MIME_TYPES = {
json: "application/json", json: "application/json",
// excalidraw data // excalidraw data

View File

@ -218,7 +218,10 @@
"libraryElementTypeError": { "libraryElementTypeError": {
"embeddable": "Embeddable elements cannot be added to the library.", "embeddable": "Embeddable elements cannot be added to the library.",
"image": "Support for adding images to the library coming soon!" "image": "Support for adding images to the library coming soon!"
} },
"asyncPasteFailedOnRead": "Couldn't paste (couldn't read from system clipboard).",
"asyncPasteFailedOnParse": "Couldn't paste.",
"copyToSystemClipboardFailed": "Couldn't copy to clipboard."
}, },
"toolBar": { "toolBar": {
"selection": "Selection", "selection": "Selection",

View File

@ -17,7 +17,7 @@ import {
} from "../element/image"; } from "../element/image";
import Scene from "./Scene"; import Scene from "./Scene";
export const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`; const SVG_EXPORT_TAG = `<!-- svg-source:excalidraw -->`;
export const exportToCanvas = async ( export const exportToCanvas = async (
elements: readonly NonDeletedExcalidrawElement[], elements: readonly NonDeletedExcalidrawElement[],

View File

@ -3,6 +3,9 @@ import "vitest-canvas-mock";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import { vi } from "vitest"; import { vi } from "vitest";
import polyfill from "./polyfill"; import polyfill from "./polyfill";
import { testPolyfills } from "./tests/helpers/polyfills";
Object.assign(globalThis, testPolyfills);
require("fake-indexeddb/auto"); require("fake-indexeddb/auto");

View File

@ -17,7 +17,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": [Function], "keyTest": [Function],
"name": "cut", "name": "cut",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -27,7 +26,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": undefined, "keyTest": undefined,
"name": "copy", "name": "copy",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -37,7 +35,6 @@ exports[`contextMenu element > right-clicking on a group should select whole gro
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -4604,7 +4601,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": [Function], "keyTest": [Function],
"name": "cut", "name": "cut",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -4614,7 +4610,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": undefined, "keyTest": undefined,
"name": "copy", "name": "copy",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -4624,7 +4619,6 @@ exports[`contextMenu element > shows 'Group selection' in context menu for multi
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -5187,7 +5181,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": [Function], "keyTest": [Function],
"name": "cut", "name": "cut",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -5197,7 +5190,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": undefined, "keyTest": undefined,
"name": "copy", "name": "copy",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -5207,7 +5199,6 @@ exports[`contextMenu element > shows 'Ungroup selection' in context menu for gro
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -5855,7 +5846,6 @@ exports[`contextMenu element > shows context menu for canvas > [end of test] app
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6109,7 +6099,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": [Function], "keyTest": [Function],
"name": "cut", "name": "cut",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6119,7 +6108,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined, "keyTest": undefined,
"name": "copy", "name": "copy",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6129,7 +6117,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6486,7 +6473,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": [Function], "keyTest": [Function],
"name": "cut", "name": "cut",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6496,7 +6482,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined, "keyTest": undefined,
"name": "copy", "name": "copy",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },
@ -6506,7 +6491,6 @@ exports[`contextMenu element > shows context menu for element > [end of test] ap
"keyTest": undefined, "keyTest": undefined,
"name": "paste", "name": "paste",
"perform": [Function], "perform": [Function],
"predicate": [Function],
"trackEvent": { "trackEvent": {
"category": "element", "category": "element",
}, },

View File

@ -1,11 +1,6 @@
import { vi } from "vitest"; import { vi } from "vitest";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { import { render, waitFor, GlobalTestState } from "./test-utils";
render,
waitFor,
GlobalTestState,
createPasteEvent,
} from "./test-utils";
import { Pointer, Keyboard } from "./helpers/ui"; import { Pointer, Keyboard } from "./helpers/ui";
import { Excalidraw } from "../packages/excalidraw/index"; import { Excalidraw } from "../packages/excalidraw/index";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
@ -16,7 +11,7 @@ import {
import { getElementBounds } from "../element"; import { getElementBounds } from "../element";
import { NormalizedZoomValue } from "../types"; import { NormalizedZoomValue } from "../types";
import { API } from "./helpers/api"; import { API } from "./helpers/api";
import { copyToClipboard } from "../clipboard"; import { createPasteEvent, serializeAsClipboardJSON } from "../clipboard";
const { h } = window; const { h } = window;
@ -37,7 +32,9 @@ vi.mock("../keys.ts", async (importOriginal) => {
const sendPasteEvent = (text: string) => { const sendPasteEvent = (text: string) => {
const clipboardEvent = createPasteEvent({ const clipboardEvent = createPasteEvent({
"text/plain": text, types: {
"text/plain": text,
},
}); });
document.dispatchEvent(clipboardEvent); document.dispatchEvent(clipboardEvent);
}; };
@ -86,7 +83,10 @@ beforeEach(async () => {
describe("general paste behavior", () => { describe("general paste behavior", () => {
it("should randomize seed on paste", async () => { it("should randomize seed on paste", async () => {
const rectangle = API.createElement({ type: "rectangle" }); const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!; const clipboardJSON = await serializeAsClipboardJSON({
elements: [rectangle],
files: null,
});
pasteWithCtrlCmdV(clipboardJSON); pasteWithCtrlCmdV(clipboardJSON);
await waitFor(() => { await waitFor(() => {
@ -97,7 +97,10 @@ describe("general paste behavior", () => {
it("should retain seed on shift-paste", async () => { it("should retain seed on shift-paste", async () => {
const rectangle = API.createElement({ type: "rectangle" }); const rectangle = API.createElement({ type: "rectangle" });
const clipboardJSON = (await copyToClipboard([rectangle], null))!; const clipboardJSON = await serializeAsClipboardJSON({
elements: [rectangle],
files: null,
});
// assert we don't randomize seed on shift-paste // assert we don't randomize seed on shift-paste
pasteWithCtrlCmdShiftV(clipboardJSON); pasteWithCtrlCmdShiftV(clipboardJSON);

View File

@ -83,6 +83,7 @@ describe("contextMenu element", () => {
const contextMenuOptions = const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li"); contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [ const expectedShortcutNames: ShortcutName[] = [
"paste",
"selectAll", "selectAll",
"gridMode", "gridMode",
"zenMode", "zenMode",
@ -114,6 +115,9 @@ describe("contextMenu element", () => {
const contextMenuOptions = const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li"); contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [ const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",
@ -203,6 +207,9 @@ describe("contextMenu element", () => {
const contextMenuOptions = const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li"); contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [ const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",
@ -256,6 +263,9 @@ describe("contextMenu element", () => {
const contextMenuOptions = const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li"); contextMenu?.querySelectorAll(".context-menu li");
const expectedShortcutNames: ShortcutName[] = [ const expectedShortcutNames: ShortcutName[] = [
"cut",
"copy",
"paste",
"copyStyles", "copyStyles",
"pasteStyles", "pasteStyles",
"deleteSelectedElements", "deleteSelectedElements",

View File

@ -1,6 +1,5 @@
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { import {
createPasteEvent,
fireEvent, fireEvent,
GlobalTestState, GlobalTestState,
render, render,
@ -27,6 +26,7 @@ import { vi } from "vitest";
import * as blob from "../data/blob"; import * as blob from "../data/blob";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
import { getBoundTextElementPosition } from "../element/textElement"; import { getBoundTextElementPosition } from "../element/textElement";
import { createPasteEvent } from "../clipboard";
const { h } = window; const { h } = window;
const mouse = new Pointer("mouse"); const mouse = new Pointer("mouse");
@ -727,7 +727,7 @@ describe("freedraw", () => {
describe("image", () => { describe("image", () => {
const createImage = async () => { const createImage = async () => {
const sendPasteEvent = (file?: File) => { const sendPasteEvent = (file?: File) => {
const clipboardEvent = createPasteEvent({}, file ? [file] : []); const clipboardEvent = createPasteEvent({ files: file ? [file] : [] });
document.dispatchEvent(clipboardEvent); document.dispatchEvent(clipboardEvent);
}; };

View File

@ -0,0 +1,91 @@
class ClipboardEvent {
constructor(
type: "paste" | "copy",
eventInitDict: {
clipboardData: DataTransfer;
},
) {
return Object.assign(
new Event("paste", {
bubbles: true,
cancelable: true,
composed: true,
}),
{
clipboardData: eventInitDict.clipboardData,
},
) as any as ClipboardEvent;
}
}
type DataKind = "string" | "file";
class DataTransferItem {
kind: DataKind;
type: string;
data: string | Blob;
constructor(kind: DataKind, type: string, data: string | Blob) {
this.kind = kind;
this.type = type;
this.data = data;
}
getAsString(callback: (data: string) => void): void {
if (this.kind === "string") {
callback(this.data as string);
}
}
getAsFile(): File | null {
if (this.kind === "file" && this.data instanceof File) {
return this.data;
}
return null;
}
}
class DataTransferList {
items: DataTransferItem[] = [];
add(data: string | File, type: string = ""): void {
if (typeof data === "string") {
this.items.push(new DataTransferItem("string", type, data));
} else if (data instanceof File) {
this.items.push(new DataTransferItem("file", type, data));
}
}
clear(): void {
this.items = [];
}
}
class DataTransfer {
public items: DataTransferList = new DataTransferList();
private _types: Record<string, string> = {};
get files() {
return this.items.items
.filter((item) => item.kind === "file")
.map((item) => item.getAsFile()!);
}
add(data: string | File, type: string = ""): void {
this.items.add(data, type);
}
setData(type: string, value: string) {
this._types[type] = value;
}
getData(type: string) {
return this._types[type] || "";
}
}
export const testPolyfills = {
ClipboardEvent,
DataTransfer,
DataTransferItem,
};

View File

@ -208,26 +208,6 @@ export const assertSelectedElements = (
expect(selectedElementIds).toEqual(expect.arrayContaining(ids)); expect(selectedElementIds).toEqual(expect.arrayContaining(ids));
}; };
export const createPasteEvent = <T extends "text/plain" | "text/html">(
items: Record<T, string>,
files?: File[],
) => {
return Object.assign(
new Event("paste", {
bubbles: true,
cancelable: true,
composed: true,
}),
{
clipboardData: {
getData: (type: string) =>
(items as Record<string, string>)[type] || "",
files: files || [],
},
},
) as any as ClipboardEvent;
};
export const toggleMenu = (container: HTMLElement) => { export const toggleMenu = (container: HTMLElement) => {
// open menu // open menu
fireEvent.click(container.querySelector(".dropdown-menu-button")!); fireEvent.click(container.querySelector(".dropdown-menu-button")!);

View File

@ -917,3 +917,17 @@ export const isRenderThrottlingEnabled = (() => {
return false; return false;
}; };
})(); })();
/** Checks if value is inside given collection. Useful for type-safety. */
export const isMemberOf = <T extends string>(
/** Set/Map/Array/Object */
collection: Set<T> | readonly T[] | Record<T, any> | Map<T, any>,
/** value to look for */
value: string,
): value is T => {
return collection instanceof Set || collection instanceof Map
? collection.has(value as T)
: "includes" in collection
? collection.includes(value as T)
: collection.hasOwnProperty(value);
};