diff --git a/src/actions/actionClipboard.tsx b/src/actions/actionClipboard.tsx index b3794d0c0..5df06a3cf 100644 --- a/src/actions/actionClipboard.tsx +++ b/src/actions/actionClipboard.tsx @@ -10,26 +10,34 @@ 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, _, app) => { const elementsToCopy = app.scene.getSelectedElements({ selectedElementIds: appState.selectedElementIds, includeBoundTextElement: true, includeElementsInFrames: true, }); - copyToClipboard(elementsToCopy, app.files); + try { + await copyToClipboard(elementsToCopy, app.files); + } 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 +46,91 @@ 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) => { + const MIME_TYPES: Record = {}; + try { + try { + const clipboardItems = await navigator.clipboard?.read(); + for (const item of clipboardItems) { + for (const type of item.types) { + try { + const blob = await item.getType(type); + MIME_TYPES[type] = await blob.text(); + } catch (error: any) { + console.warn( + `Cannot retrieve ${type} from clipboardItem: ${error.message}`, + ); + } + } + } + if (Object.keys(MIME_TYPES).length === 0) { + console.warn( + "No clipboard data found from clipboard.read(). Falling back to clipboard.readText()", + ); + // throw so we fall back onto clipboard.readText() + throw new Error("No clipboard data found"); + } + } catch (error: any) { + try { + MIME_TYPES["text/plain"] = await navigator.clipboard?.readText(); + } catch (error: any) { + console.warn(`Cannot readText() from clipboard: ${error.message}`); + if (isFirefox) { + return { + commitToHistory: false, + appState: { + ...appState, + errorMessage: t("hints.firefox_clipboard_write"), + }, + }; + } + throw error; + } + } + } catch (error: any) { + console.error(`actionPaste: ${error.message}`); + return { + commitToHistory: false, + appState: { + ...appState, + errorMessage: error.message, + }, + }; + } + try { + console.log("actionPaste (1)", { MIME_TYPES }); + const event = new ClipboardEvent("paste", { + clipboardData: new DataTransfer(), + }); + for (const [type, value] of Object.entries(MIME_TYPES)) { + try { + event.clipboardData?.setData(type, value); + } catch (error: any) { + console.warn( + `Cannot set ${type} as clipboardData item: ${error.message}`, + ); + } + } + event.clipboardData?.types.forEach((type) => { + console.log( + `actionPaste (2) event.clipboardData?.getData(${type})`, + event.clipboardData?.getData(type), + ); + }); + app.pasteFromClipboard(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.paste", // don't supply a shortcut since we handle this conditionally via onCopy event keyTest: undefined, diff --git a/src/clipboard.ts b/src/clipboard.ts index bc2167a09..df9343660 100644 --- a/src/clipboard.ts +++ b/src/clipboard.ts @@ -118,7 +118,7 @@ export const copyToClipboard = async ( await copyTextToSystemClipboard(json); } catch (error: any) { PREFER_APP_CLIPBOARD = true; - console.error(error); + throw error; } }; @@ -193,7 +193,7 @@ const maybeParseHTMLPaste = (event: ClipboardEvent) => { * via async clipboard API if supported) */ const getSystemClipboard = async ( - event: ClipboardEvent | null, + event: ClipboardEvent, isPlainPaste = false, ): Promise< | { type: "text"; value: string } @@ -205,10 +205,7 @@ const getSystemClipboard = async ( return { type: "mixedContent", value: 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,7 +217,7 @@ 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); diff --git a/src/components/App.tsx b/src/components/App.tsx index a697b28be..893819275 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={(cb) => { + this.setState({ contextMenu: null }, () => { + this.focusContainer(); + cb?.(); + }); + }} /> )} { }; public pasteFromClipboard = withBatchedUpdates( - async (event: ClipboardEvent | null) => { + async (event: ClipboardEvent) => { const isPlainPaste = !!(IS_PLAIN_PASTE && event); + console.warn( + "pasteFromClipboard", + event?.clipboardData?.types, + event?.clipboardData?.getData("text/plain"), + ); + // #686 const target = document.activeElement; const isExcalidrawActive = this.excalidrawContainerRef.current?.contains(target); if (event && !isExcalidrawActive) { + console.log("exit (1)"); return; } @@ -2215,6 +2228,7 @@ class App extends React.Component { (!(elementUnderCursor instanceof HTMLCanvasElement) || isWritableElement(target)) ) { + console.log("exit (2)"); return; } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index 42d654207..81c0dd24e 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: (cb?: () => 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,7 @@ export const ContextMenu = React.memo( return ( setAppState({ contextMenu: null })} + onCloseRequest={() => onClose()} top={top} left={left} fitInViewport={true} @@ -102,7 +98,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"); }); }}