From 954d805cb339edec7e2f5325858afb8fcba91d0a Mon Sep 17 00:00:00 2001 From: David Luzar Date: Tue, 4 Feb 2020 11:50:18 +0100 Subject: [PATCH] rewrite clipboard handling (#689) --- .lintstagedrc.js | 2 +- src/clipboard.ts | 144 +++++++++++++++++++++++++++++ src/components/ExportDialog.tsx | 8 +- src/index.tsx | 154 ++++++++++++++++---------------- src/scene/data.ts | 33 ++++--- src/utils.ts | 17 ++++ 6 files changed, 259 insertions(+), 99 deletions(-) create mode 100644 src/clipboard.ts diff --git a/.lintstagedrc.js b/.lintstagedrc.js index 18e1d218..06fb1d16 100644 --- a/.lintstagedrc.js +++ b/.lintstagedrc.js @@ -7,7 +7,7 @@ const cli = new CLIEngine({}); module.exports = { "*.{js,ts,tsx}": files => { return ( - "eslint --fix" + files.filter(file => !cli.isPathIgnored(file)).join(" ") + "eslint --fix " + files.filter(file => !cli.isPathIgnored(file)).join(" ") ); }, "*.{css,scss,json,md,html,yml}": ["prettier --write"], diff --git a/src/clipboard.ts b/src/clipboard.ts new file mode 100644 index 00000000..372f123e --- /dev/null +++ b/src/clipboard.ts @@ -0,0 +1,144 @@ +import { ExcalidrawElement } from "./element/types"; + +let CLIPBOARD = ""; +let PREFER_APP_CLIPBOARD = false; + +export const probablySupportsClipboardWriteText = + "clipboard" in navigator && "writeText" in navigator.clipboard; + +export const probablySupportsClipboardBlob = + "clipboard" in navigator && + "write" in navigator.clipboard && + "ClipboardItem" in window && + "toBlob" in HTMLCanvasElement.prototype; + +export async function copyToAppClipboard( + elements: readonly ExcalidrawElement[], +) { + CLIPBOARD = JSON.stringify( + elements + .filter(element => element.isSelected) + .map(({ shape, ...el }) => el), + ); + try { + // when copying to in-app clipboard, clear system clipboard so that if + // system clip contains text on paste we know it was copied *after* user + // copied elements, and thus we should prefer the text content. + await copyTextToSystemClipboard(null); + PREFER_APP_CLIPBOARD = false; + } catch (err) { + // if clearing system clipboard didn't work, we should prefer in-app + // clipboard even if there's text in system clipboard on paste, because + // we can't be sure of the order of copy operations + PREFER_APP_CLIPBOARD = true; + } +} + +export function getAppClipboard(): { + elements?: readonly ExcalidrawElement[]; +} { + try { + const clipboardElements = JSON.parse(CLIPBOARD); + + if ( + Array.isArray(clipboardElements) && + clipboardElements.length > 0 && + clipboardElements[0].type // need to implement a better check here... + ) { + return { elements: clipboardElements }; + } + } catch (err) {} + + return {}; +} + +export function parseClipboardEvent( + e: ClipboardEvent, +): { + text?: string; + elements?: readonly ExcalidrawElement[]; +} { + try { + const text = e.clipboardData?.getData("text/plain").trim(); + if (text && !PREFER_APP_CLIPBOARD) { + return { text }; + // eslint-disable-next-line no-else-return + } else { + return getAppClipboard(); + } + } catch (e) {} + + return {}; +} + +export async function copyCanvasToClipboardAsPng(canvas: HTMLCanvasElement) { + return new Promise((resolve, reject) => { + try { + canvas.toBlob(async function(blob: any) { + try { + await navigator.clipboard.write([ + new window.ClipboardItem({ "image/png": blob }), + ]); + resolve(); + } catch (err) { + reject(err); + } + }); + } catch (err) { + reject(err); + } + }); +} + +export async function copyTextToSystemClipboard(text: string | null) { + let copied = false; + 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; + } catch (err) {} + } + + // 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"); + } +} + +// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48 +function copyTextViaExecCommand(text: string) { + const isRTL = document.documentElement.getAttribute("dir") === "rtl"; + + const textarea = document.createElement("textarea"); + + textarea.style.border = "0"; + textarea.style.padding = "0"; + textarea.style.margin = "0"; + textarea.style.position = "absolute"; + textarea.style[isRTL ? "right" : "left"] = "-9999px"; + const yPosition = window.pageYOffset || document.documentElement.scrollTop; + textarea.style.top = `${yPosition}px`; + // Prevent zooming on iOS + textarea.style.fontSize = "12pt"; + + textarea.setAttribute("readonly", ""); + textarea.value = text; + + document.body.appendChild(textarea); + + let success = false; + + try { + textarea.select(); + textarea.setSelectionRange(0, textarea.value.length); + + success = document.execCommand("copy"); + } catch (err) {} + + textarea.remove(); + + return success; +} diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index 7eeaa895..44abea87 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -15,11 +15,7 @@ import { t } from "../i18n"; import { KEYS } from "../keys"; -const probablySupportsClipboard = - "toBlob" in HTMLCanvasElement.prototype && - "clipboard" in navigator && - "write" in navigator.clipboard && - "ClipboardItem" in window; +import { probablySupportsClipboardBlob } from "../clipboard"; const scales = [1, 2, 3]; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; @@ -145,7 +141,7 @@ function ExportModal({ aria-label={t("buttons.exportToSvg")} onClick={() => onExportToSvg(exportedElements, scale)} /> - {probablySupportsClipboard && ( + {probablySupportsClipboardBlob && ( { }; private onCut = (e: ClipboardEvent) => { - if (isInputLike(e.target) && !isToolIcon(e.target)) { + if (isWritableElement(e.target)) { return; } - e.clipboardData?.setData( - "text/plain", - JSON.stringify( - elements - .filter(element => element.isSelected) - .map(({ shape, ...el }) => el), - ), - ); + copyToAppClipboard(elements); elements = deleteSelectedElements(elements); this.setState({}); e.preventDefault(); }; private onCopy = (e: ClipboardEvent) => { - if (isInputLike(e.target) && !isToolIcon(e.target)) { + if (isWritableElement(e.target)) { return; } - e.clipboardData?.setData( - "text/plain", - JSON.stringify( - elements - .filter(element => element.isSelected) - .map(({ shape, ...el }) => el), - ), - ); + copyToAppClipboard(elements); e.preventDefault(); }; private onPaste = (e: ClipboardEvent) => { - if (isInputLike(e.target) && !isToolIcon(e.target)) { - return; + // #686 + const target = document.activeElement; + const elementUnderCursor = document.elementFromPoint(cursorX, cursorY); + if ( + elementUnderCursor instanceof HTMLCanvasElement && + !isWritableElement(target) + ) { + const data = parseClipboardEvent(e); + if (data.elements) { + this.addElementsFromPaste(data.elements); + } else if (data.text) { + const { x, y } = viewportCoordsToSceneCoords( + { clientX: cursorX, clientY: cursorY }, + this.state, + ); + + const element = newTextElement( + newElement( + "text", + x, + y, + this.state.currentItemStrokeColor, + this.state.currentItemBackgroundColor, + this.state.currentItemFillStyle, + this.state.currentItemStrokeWidth, + this.state.currentItemRoughness, + this.state.currentItemOpacity, + ), + data.text, + this.state.currentItemFont, + ); + + element.isSelected = true; + + elements = [...clearSelection(elements), element]; + this.setState({}); + } + e.preventDefault(); } - const paste = e.clipboardData?.getData("text") || ""; - this.addElementsFromPaste(paste); - e.preventDefault(); }; private onUnload = () => { @@ -452,24 +477,14 @@ export class App extends React.Component { private removeWheelEventListener: (() => void) | undefined; - private copyToClipboard = () => { - const text = JSON.stringify( - elements - .filter(element => element.isSelected) - .map(({ shape, ...el }) => el), - ); - if ("clipboard" in navigator && "writeText" in navigator.clipboard) { - navigator.clipboard.writeText(text); - } else { - document.execCommand("copy"); - } + private copyToAppClipboard = () => { + copyToAppClipboard(elements); }; private pasteFromClipboard = () => { - if ("clipboard" in navigator && "readText" in navigator.clipboard) { - navigator.clipboard - .readText() - .then(text => this.addElementsFromPaste(text)); + const data = getAppClipboard(); + if (data.elements) { + this.addElementsFromPaste(data.elements); } }; @@ -809,7 +824,7 @@ export class App extends React.Component { options: [ navigator.clipboard && { label: t("labels.copy"), - action: this.copyToClipboard, + action: this.copyToAppClipboard, }, navigator.clipboard && { label: t("labels.paste"), @@ -1835,45 +1850,34 @@ export class App extends React.Component { }); }; - private addElementsFromPaste = (paste: string) => { - let parsedElements; - try { - parsedElements = JSON.parse(paste); - } catch (e) {} - if ( - Array.isArray(parsedElements) && - parsedElements.length > 0 && - parsedElements[0].type // need to implement a better check here... - ) { - elements = clearSelection(elements); + private addElementsFromPaste = ( + clipboardElements: readonly ExcalidrawElement[], + ) => { + elements = clearSelection(elements); - const [minX, minY, maxX, maxY] = getCommonBounds(parsedElements); + const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements); - const elementsCenterX = distance(minX, maxX) / 2; - const elementsCenterY = distance(minY, maxY) / 2; + const elementsCenterX = distance(minX, maxX) / 2; + const elementsCenterY = distance(minY, maxY) / 2; - const dx = - cursorX - - this.state.scrollX - - CANVAS_WINDOW_OFFSET_LEFT - - elementsCenterX; - const dy = - cursorY - - this.state.scrollY - - CANVAS_WINDOW_OFFSET_TOP - - elementsCenterY; + const dx = + cursorX - + this.state.scrollX - + CANVAS_WINDOW_OFFSET_LEFT - + elementsCenterX; + const dy = + cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY; - elements = [ - ...elements, - ...parsedElements.map(parsedElement => { - const duplicate = duplicateElement(parsedElement); - duplicate.x += dx - minX; - duplicate.y += dy - minY; - return duplicate; - }), - ]; - this.setState({}); - } + elements = [ + ...elements, + ...clipboardElements.map(clipboardElements => { + const duplicate = duplicateElement(clipboardElements); + duplicate.x += dx - minX; + duplicate.y += dy - minY; + return duplicate; + }), + ]; + this.setState({}); }; private getTextWysiwygSnappedToCenterPosition(x: number, y: number) { diff --git a/src/scene/data.ts b/src/scene/data.ts index 4e02a4bd..a5dea245 100644 --- a/src/scene/data.ts +++ b/src/scene/data.ts @@ -11,6 +11,10 @@ import { getCommonBounds, normalizeDimensions } from "../element"; import { Point } from "roughjs/bin/geometry"; import { t } from "../i18n"; +import { + copyTextToSystemClipboard, + copyCanvasToClipboardAsPng, +} from "../clipboard"; const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes"; @@ -150,12 +154,16 @@ export async function exportToBackend( const url = new URL(window.location.href); url.searchParams.append("id", json.id); - await navigator.clipboard.writeText(url.toString()); - window.alert( - t("alerts.copiedToClipboard", { - url: url.toString(), - }), - ); + try { + await copyTextToSystemClipboard(url.toString()); + window.alert( + t("alerts.copiedToClipboard", { + url: url.toString(), + }), + ); + } catch (err) { + // TODO: link will be displayed for user to copy manually in later PR + } } else { window.alert(t("alerts.couldNotCreateShareableLink")); } @@ -241,19 +249,10 @@ export async function exportCanvas( } }); } else if (type === "clipboard") { - const errorMsg = t("alerts.couldNotCopyToClipboard"); try { - tempCanvas.toBlob(async function(blob: any) { - try { - await navigator.clipboard.write([ - new window.ClipboardItem({ "image/png": blob }), - ]); - } catch (err) { - window.alert(errorMsg); - } - }); + copyCanvasToClipboardAsPng(tempCanvas); } catch (err) { - window.alert(errorMsg); + window.alert(t("alerts.couldNotCopyToClipboard")); } } else if (type === "backend") { const appState = getDefaultAppState(); diff --git a/src/utils.ts b/src/utils.ts index 74746b74..1470f364 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -28,6 +28,7 @@ export function isInputLike( | HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement + | HTMLBRElement | HTMLDivElement { return ( (target instanceof HTMLElement && target.dataset.type === "wysiwyg") || @@ -38,6 +39,22 @@ export function isInputLike( ); } +export function isWritableElement( + target: Element | EventTarget | null, +): target is + | HTMLInputElement + | HTMLTextAreaElement + | HTMLBRElement + | HTMLDivElement { + return ( + (target instanceof HTMLElement && target.dataset.type === "wysiwyg") || + target instanceof HTMLBRElement || // newline in wysiwyg + target instanceof HTMLTextAreaElement || + (target instanceof HTMLInputElement && + (target.type === "text" || target.type === "number")) + ); +} + // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js export function measureText(text: string, font: string) { const line = document.createElement("div");