From ea677d45819fa71919604e0d1d60c518045cd966 Mon Sep 17 00:00:00 2001 From: David Luzar <5153846+dwelle@users.noreply.github.com> Date: Sat, 28 Oct 2023 21:29:28 +0200 Subject: [PATCH] feat: make clipboard more robust and reintroduce contextmenu actions (#7198) --- src/actions/actionClipboard.tsx | 77 ++++- src/actions/manager.tsx | 6 +- src/clipboard.test.ts | 194 +++++++++++- src/clipboard.ts | 299 ++++++++++++------ src/components/App.tsx | 22 +- src/components/ContextMenu.tsx | 16 +- src/constants.ts | 2 + src/locales/en.json | 5 +- src/scene/export.ts | 2 +- src/setupTests.ts | 3 + .../__snapshots__/contextmenu.test.tsx.snap | 16 - src/tests/clipboard.test.tsx | 23 +- src/tests/contextmenu.test.tsx | 10 + src/tests/flip.test.tsx | 4 +- src/tests/helpers/polyfills.ts | 91 ++++++ src/tests/test-utils.ts | 20 -- src/utils.ts | 14 + 17 files changed, 611 insertions(+), 193 deletions(-) create mode 100644 src/tests/helpers/polyfills.ts diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index b3794d0c..03fdc6a4 100644 --- a/src/actions/actionClipboard.tsx +++ b/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, }); diff --git a/src/actions/manager.tsx b/src/actions/manager.tsx index b68b59d5..3cc0b5b8 100644 --- a/src/actions/manager.tsx +++ b/src/actions/manager.tsx @@ -119,10 +119,10 @@ export class ActionManager { return true; } - executeAction( - action: Action, + executeAction( + action: T, source: ActionSource = "api", - value: any = null, + value: Parameters[2] = null, ) { const elements = this.getElementsIncludingDeleted(); const appState = this.getAppState(); diff --git a/src/clipboard.test.ts b/src/clipboard.test.ts index f1717464..770bcc90 100644 --- a/src/clipboard.test.ts +++ b/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]"; - 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": `
${json}
`, + }, + }), + ); + expect(clipboardData.elements).toEqual([rect]); + // ------------------------------------------------------------------------- + }); + + it("should parse `src` urls out of text/html", async () => { + let clipboardData; + // ------------------------------------------------------------------------- + clipboardData = await parseClipboard( + createPasteEvent({ + types: { + "text/html": ``, + }, + }), + ); + expect(clipboardData.mixedContent).toEqual([ + { + type: "imageUrl", + value: "https://example.com/image.png", + }, + ]); + // ------------------------------------------------------------------------- + clipboardData = await parseClipboard( + createPasteEvent({ + types: { + "text/html": `
`, + }, + }), + ); + 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 `src` urls out of text/html", async () => { + const clipboardData = await parseClipboard( + createPasteEvent({ + types: { + "text/html": `hello
my friend!`, + }, + }), + ); + 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": ` + +
ab
12
45
710
+ + `, + "text/plain": `a b + 1 2 + 4 5 + 7 10`, + }, + }), + ); + expect(clipboardData.spreadsheet).toEqual({ + title: "b", + labels: ["1", "4", "7"], + values: [2, 5, 10], + }); + }); }); diff --git a/src/clipboard.ts b/src/clipboard.ts index bc2167a0..32b0edf1 100644 --- a/src/clipboard.ts +++ b/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 => { - 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 => { 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 => { - 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(); + 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; + } + } 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 { - 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) => { @@ -310,28 +400,49 @@ export const copyBlobToClipboardAsPng = async (blob: Blob | Promise) => { } }; -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"); diff --git a/src/components/App.tsx b/src/components/App.tsx index a697b28b..be81d761 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1275,6 +1275,12 @@ class App extends React.Component { top={this.state.contextMenu.top} left={this.state.contextMenu.left} actionManager={this.actionManager} + onClose={(callback) => { + this.setState({ contextMenu: null }, () => { + this.focusContainer(); + callback?.(); + }); + }} /> )} { 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 { 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 { }; 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; diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 42d65420..ebabae83 100644 --- a/src/components/ContextMenu.tsx +++ b/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 ( 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"); }); }} diff --git a/src/constants.ts b/src/constants.ts index 07eb6795..9c22f71d 100644 --- a/src/constants.ts +++ b/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 diff --git a/src/locales/en.json b/src/locales/en.json index 19dbf81f..8a2cc4d6 100644 --- a/src/locales/en.json +++ b/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", diff --git a/src/scene/export.ts b/src/scene/export.ts index b94dc27c..3aa0cec6 100644 --- a/src/scene/export.ts +++ b/src/scene/export.ts @@ -17,7 +17,7 @@ import { } from "../element/image"; import Scene from "./Scene"; -export const SVG_EXPORT_TAG = ``; +const SVG_EXPORT_TAG = ``; export const exportToCanvas = async ( elements: readonly NonDeletedExcalidrawElement[], diff --git a/src/setupTests.ts b/src/setupTests.ts index 342e532d..3a328ce4 100644 --- a/src/setupTests.ts +++ b/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"); diff --git a/src/tests/__snapshots__/contextmenu.test.tsx.snap b/src/tests/__snapshots__/contextmenu.test.tsx.snap index e141c011..d8ef5572 100644 --- a/src/tests/__snapshots__/contextmenu.test.tsx.snap +++ b/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", }, diff --git a/src/tests/clipboard.test.tsx b/src/tests/clipboard.test.tsx index 0ea480ad..52295a12 100644 --- a/src/tests/clipboard.test.tsx +++ b/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); diff --git a/src/tests/contextmenu.test.tsx b/src/tests/contextmenu.test.tsx index 2d556d3a..67312191 100644 --- a/src/tests/contextmenu.test.tsx +++ b/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", diff --git a/src/tests/flip.test.tsx b/src/tests/flip.test.tsx index 982105e9..d384f122 100644 --- a/src/tests/flip.test.tsx +++ b/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); }; diff --git a/src/tests/helpers/polyfills.ts b/src/tests/helpers/polyfills.ts new file mode 100644 index 00000000..8b866b6c --- /dev/null +++ b/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 = {}; + + 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, +}; diff --git a/src/tests/test-utils.ts b/src/tests/test-utils.ts index 0f80182a..fdc8b02e 100644 --- a/src/tests/test-utils.ts +++ b/src/tests/test-utils.ts @@ -208,26 +208,6 @@ export const assertSelectedElements = ( expect(selectedElementIds).toEqual(expect.arrayContaining(ids)); }; -export const createPasteEvent = ( - items: Record, - files?: File[], -) => { - return Object.assign( - new Event("paste", { - bubbles: true, - cancelable: true, - composed: true, - }), - { - clipboardData: { - getData: (type: string) => - (items as Record)[type] || "", - files: files || [], - }, - }, - ) as any as ClipboardEvent; -}; - export const toggleMenu = (container: HTMLElement) => { // open menu fireEvent.click(container.querySelector(".dropdown-menu-button")!); diff --git a/src/utils.ts b/src/utils.ts index d5f952fa..67481960 100644 --- a/src/utils.ts +++ b/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 = ( + /** Set/Map/Array/Object */ + collection: Set | readonly T[] | Record | Map, + /** 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); +};