Quellcode durchsuchen

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

David Luzar vor 1 Jahr
Ursprung
Commit
ea677d4581

+ 62 - 15
src/actions/actionClipboard.tsx

@@ -3,33 +3,43 @@ import { register } from "./register";
 import {
   copyTextToSystemClipboard,
   copyToClipboard,
+  createPasteEvent,
   probablySupportsClipboardBlob,
   probablySupportsClipboardWriteText,
+  readSystemClipboard,
 } from "../clipboard";
 import { actionDeleteSelected } from "./actionDeleteSelected";
 import { exportCanvas } from "../data/index";
 import { getNonDeletedElements, isTextElement } from "../element";
 import { t } from "../i18n";
+import { isFirefox } from "../constants";
 
 export const actionCopy = register({
   name: "copy",
   trackEvent: { category: "element" },
-  perform: (elements, appState, _, app) => {
+  perform: async (elements, appState, event: ClipboardEvent | null, app) => {
     const elementsToCopy = app.scene.getSelectedElements({
       selectedElementIds: appState.selectedElementIds,
       includeBoundTextElement: 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 {
       commitToHistory: false,
     };
   },
-  predicate: (elements, appState, appProps, app) => {
-    return app.device.isMobile && !!navigator.clipboard;
-  },
   contextItemLabel: "labels.copy",
   // don't supply a shortcut since we handle this conditionally via onCopy event
   keyTest: undefined,
@@ -38,15 +48,55 @@ export const actionCopy = register({
 export const actionPaste = register({
   name: "paste",
   trackEvent: { category: "element" },
-  perform: (elements: any, appStates: any, data, app) => {
-    app.pasteFromClipboard(null);
+  perform: async (elements, appState, data, app) => {
+    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 {
       commitToHistory: false,
     };
   },
-  predicate: (elements, appState, appProps, app) => {
-    return app.device.isMobile && !!navigator.clipboard;
-  },
   contextItemLabel: "labels.paste",
   // don't supply a shortcut since we handle this conditionally via onCopy event
   keyTest: undefined,
@@ -55,13 +105,10 @@ export const actionPaste = register({
 export const actionCut = register({
   name: "cut",
   trackEvent: { category: "element" },
-  perform: (elements, appState, data, app) => {
-    actionCopy.perform(elements, appState, data, app);
+  perform: (elements, appState, event: ClipboardEvent | null, app) => {
+    actionCopy.perform(elements, appState, event, app);
     return actionDeleteSelected.perform(elements, appState);
   },
-  predicate: (elements, appState, appProps, app) => {
-    return app.device.isMobile && !!navigator.clipboard;
-  },
   contextItemLabel: "labels.cut",
   keyTest: (event) => event[KEYS.CTRL_OR_CMD] && event.key === KEYS.X,
 });

+ 3 - 3
src/actions/manager.tsx

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

+ 184 - 10
src/clipboard.test.ts

@@ -1,22 +1,196 @@
-import { parseClipboard } from "./clipboard";
-import { createPasteEvent } from "./tests/test-utils";
+import {
+  createPasteEvent,
+  parseClipboard,
+  serializeAsClipboardJSON,
+} from "./clipboard";
+import { API } from "./tests/helpers/api";
 
-describe("Test parseClipboard", () => {
-  it("should parse valid json correctly", async () => {
-    let text = "123";
+describe("parseClipboard()", () => {
+  it("should parse JSON as plaintext if not excalidraw-api/clipboard data", async () => {
+    let text;
+    let clipboardData;
+    // -------------------------------------------------------------------------
 
-    let clipboardData = await parseClipboard(
-      createPasteEvent({ "text/plain": text }),
+    text = "123";
+    clipboardData = await parseClipboard(
+      createPasteEvent({ types: { "text/plain": text } }),
     );
-
     expect(clipboardData.text).toBe(text);
 
-    text = "[123]";
+    // -------------------------------------------------------------------------
 
+    text = "[123]";
     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);
   });
+
+  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],
+    });
+  });
 });

+ 204 - 93
src/clipboard.ts

@@ -3,14 +3,18 @@ import {
   NonDeletedExcalidrawElement,
 } from "./element/types";
 import { BinaryFiles } from "./types";
-import { SVG_EXPORT_TAG } from "./scene/export";
 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 { deepCopyElement } from "./element/newElement";
 import { mutateElement } from "./element/mutateElement";
 import { getContainingFrame } from "./frame";
-import { isPromiseLike, isTestEnv } from "./utils";
+import { isMemberOf, isPromiseLike } from "./utils";
+import { t } from "./i18n";
 
 type ElementsClipboard = {
   type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
@@ -30,8 +34,11 @@ export interface ClipboardData {
   programmaticAPI?: boolean;
 }
 
-let CLIPBOARD = "";
-let PREFER_APP_CLIPBOARD = false;
+type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
+
+type ParsedClipboardEvent =
+  | { type: "text"; value: string }
+  | { type: "mixedContent"; value: PastedMixedContent };
 
 export const probablySupportsClipboardReadText =
   "clipboard" in navigator && "readText" in navigator.clipboard;
@@ -61,10 +68,61 @@ const clipboardContainsElements = (
   return false;
 };
 
-export const copyToClipboard = async (
-  elements: readonly NonDeletedExcalidrawElement[],
-  files: BinaryFiles | null,
-) => {
+export const createPasteEvent = ({
+  types,
+  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(
     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 = {
     type: EXPORT_DATA_TYPES.excalidrawClipboard,
     elements: elements.map((element) => {
@@ -105,34 +163,20 @@ export const copyToClipboard = async (
     }),
     files: files ? _files : undefined,
   };
-  const json = JSON.stringify(contents);
-
-  if (isTestEnv()) {
-    return json;
-  }
-
-  CLIPBOARD = json;
 
-  try {
-    PREFER_APP_CLIPBOARD = false;
-    await copyTextToSystemClipboard(json);
-  } catch (error: any) {
-    PREFER_APP_CLIPBOARD = true;
-    console.error(error);
-  }
+  return JSON.stringify(contents);
 };
 
-const getAppClipboard = (): Partial<ElementsClipboard> => {
-  if (!CLIPBOARD) {
-    return {};
-  }
-
-  try {
-    return JSON.parse(CLIPBOARD);
-  } catch (error: any) {
-    console.error(error);
-    return {};
-  }
+export const copyToClipboard = async (
+  elements: readonly NonDeletedExcalidrawElement[],
+  files: BinaryFiles | null,
+  /** supply if available to make the operation more certain to succeed */
+  clipboardEvent?: ClipboardEvent | null,
+) => {
+  await copyTextToSystemClipboard(
+    serializeAsClipboardJSON({ elements, files }),
+    clipboardEvent,
+  );
 };
 
 const parsePotentialSpreadsheet = (
@@ -166,7 +210,9 @@ function parseHTMLTree(el: ChildNode) {
   return result;
 }
 
-const maybeParseHTMLPaste = (event: ClipboardEvent) => {
+const maybeParseHTMLPaste = (
+  event: ClipboardEvent,
+): { type: "mixedContent"; value: PastedMixedContent } | null => {
   const html = event.clipboardData?.getData("text/html");
 
   if (!html) {
@@ -179,7 +225,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
     const content = parseHTMLTree(doc.body);
 
     if (content.length) {
-      return content;
+      return { type: "mixedContent", value: content };
     }
   } catch (error: any) {
     console.error(`error in parseHTMLFromPaste: ${error.message}`);
@@ -188,27 +234,88 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => {
   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
- *  via async clipboard API if supported)
+ * Parses "paste" ClipboardEvent.
  */
-const getSystemClipboard = async (
-  event: ClipboardEvent | null,
+const parseClipboardEvent = async (
+  event: ClipboardEvent,
   isPlainPaste = false,
-): Promise<
-  | { type: "text"; value: string }
-  | { type: "mixedContent"; value: PastedMixedContent }
-> => {
+): Promise<ParsedClipboardEvent> => {
   try {
     const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
+
     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
-      ? event.clipboardData?.getData("text/plain")
-      : probablySupportsClipboardReadText &&
-        (await navigator.clipboard.readText());
+    const text = event.clipboardData?.getData("text/plain");
 
     return { type: "text", value: (text || "").trim() };
   } catch {
@@ -220,40 +327,32 @@ const getSystemClipboard = async (
  * Attempts to parse clipboard. Prefers system clipboard.
  */
 export const parseClipboard = async (
-  event: ClipboardEvent | null,
+  event: ClipboardEvent,
   isPlainPaste = false,
 ): Promise<ClipboardData> => {
-  const systemClipboard = await getSystemClipboard(event, isPlainPaste);
+  const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
 
-  if (systemClipboard.type === "mixedContent") {
+  if (parsedEventData.type === "mixedContent") {
     return {
-      mixedContent: systemClipboard.value,
+      mixedContent: parsedEventData.value,
     };
   }
 
-  // if system clipboard empty, couldn't be resolved, or contains previously
-  // copied excalidraw scene as SVG, fall back to previously copied excalidraw
-  // elements
-  if (
-    !systemClipboard ||
-    (!isPlainPaste && systemClipboard.value.includes(SVG_EXPORT_TAG))
-  ) {
-    return getAppClipboard();
-  }
-
-  // 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);
+  try {
+    // if system clipboard contains spreadsheet, use it even though it's
+    // technically possible it's staler than in-app clipboard
+    const spreadsheetResult =
+      !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
 
-  if (spreadsheetResult) {
-    return spreadsheetResult;
+    if (spreadsheetResult) {
+      return spreadsheetResult;
+    }
+  } catch (error: any) {
+    console.error(error);
   }
 
-  const appClipboardData = getAppClipboard();
-
   try {
-    const systemClipboardData = JSON.parse(systemClipboard.value);
+    const systemClipboardData = JSON.parse(parsedEventData.value);
     const programmaticAPI =
       systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
     if (clipboardContainsElements(systemClipboardData)) {
@@ -266,18 +365,9 @@ export const parseClipboard = async (
         programmaticAPI,
       };
     }
-  } catch (e) {}
-  // system clipboard doesn't contain excalidraw elements → return plaintext
-  // unless we set a flag to prefer in-app clipboard because browser didn't
-  // 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 };
+  } catch {}
+
+  return { text: parsedEventData.value };
 };
 
 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) => {
-  let copied = false;
+export const copyTextToSystemClipboard = async (
+  text: string | null,
+  clipboardEvent?: ClipboardEvent | null,
+) => {
+  // (1) first try using Async Clipboard API
   if (probablySupportsClipboardWriteText) {
     try {
       // NOTE: doesn't work on FF on non-HTTPS domains, or when document
       // not focused
       await navigator.clipboard.writeText(text || "");
-      copied = true;
+      return;
     } catch (error: any) {
       console.error(error);
     }
   }
 
-  // Note that 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 (!copied && !copyTextViaExecCommand(text || " ")) {
-    throw new Error("couldn't copy");
+  // (2) if fails and we have access to ClipboardEvent, use plain old setData()
+  try {
+    if (clipboardEvent) {
+      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
-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 textarea = document.createElement("textarea");

+ 10 - 12
src/components/App.tsx

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

+ 7 - 9
src/components/ContextMenu.tsx

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

+ 2 - 0
src/constants.ts

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

+ 4 - 1
src/locales/en.json

@@ -218,7 +218,10 @@
     "libraryElementTypeError": {
       "embeddable": "Embeddable elements cannot be added to the library.",
       "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": {
     "selection": "Selection",

+ 1 - 1
src/scene/export.ts

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

+ 3 - 0
src/setupTests.ts

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

+ 0 - 16
src/tests/__snapshots__/contextmenu.test.tsx.snap

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

+ 13 - 10
src/tests/clipboard.test.tsx

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

+ 10 - 0
src/tests/contextmenu.test.tsx

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

+ 2 - 2
src/tests/flip.test.tsx

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

+ 91 - 0
src/tests/helpers/polyfills.ts

@@ -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,
+};

+ 0 - 20
src/tests/test-utils.ts

@@ -208,26 +208,6 @@ export const assertSelectedElements = (
   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) => {
   // open menu
   fireEvent.click(container.querySelector(".dropdown-menu-button")!);

+ 14 - 0
src/utils.ts

@@ -917,3 +917,17 @@ export const isRenderThrottlingEnabled = (() => {
     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);
+};