rewrite clipboard handling (#689)

This commit is contained in:
David Luzar 2020-02-04 11:50:18 +01:00 committed by GitHub
parent dab35c9033
commit 954d805cb3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 259 additions and 99 deletions

View File

@ -7,7 +7,7 @@ const cli = new CLIEngine({});
module.exports = { module.exports = {
"*.{js,ts,tsx}": files => { "*.{js,ts,tsx}": files => {
return ( 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"], "*.{css,scss,json,md,html,yml}": ["prettier --write"],

144
src/clipboard.ts Normal file
View File

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

View File

@ -15,11 +15,7 @@ import { t } from "../i18n";
import { KEYS } from "../keys"; import { KEYS } from "../keys";
const probablySupportsClipboard = import { probablySupportsClipboardBlob } from "../clipboard";
"toBlob" in HTMLCanvasElement.prototype &&
"clipboard" in navigator &&
"write" in navigator.clipboard &&
"ClipboardItem" in window;
const scales = [1, 2, 3]; const scales = [1, 2, 3];
const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1; const defaultScale = scales.includes(devicePixelRatio) ? devicePixelRatio : 1;
@ -145,7 +141,7 @@ function ExportModal({
aria-label={t("buttons.exportToSvg")} aria-label={t("buttons.exportToSvg")}
onClick={() => onExportToSvg(exportedElements, scale)} onClick={() => onExportToSvg(exportedElements, scale)}
/> />
{probablySupportsClipboard && ( {probablySupportsClipboardBlob && (
<ToolButton <ToolButton
type="button" type="button"
icon={clipboard} icon={clipboard}

View File

@ -45,11 +45,11 @@ import { ExcalidrawElement } from "./element/types";
import { import {
isInputLike, isInputLike,
isWritableElement,
debounce, debounce,
capitalizeString, capitalizeString,
distance, distance,
distance2d, distance2d,
isToolIcon,
} from "./utils"; } from "./utils";
import { KEYS, isArrowKey } from "./keys"; import { KEYS, isArrowKey } from "./keys";
@ -100,6 +100,12 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
import { StoredScenesList } from "./components/StoredScenesList"; import { StoredScenesList } from "./components/StoredScenesList";
import { HintViewer } from "./components/HintViewer"; import { HintViewer } from "./components/HintViewer";
import {
getAppClipboard,
copyToAppClipboard,
parseClipboardEvent,
} from "./clipboard";
let { elements } = createScene(); let { elements } = createScene();
const { history } = createHistory(); const { history } = createHistory();
@ -222,42 +228,61 @@ export class App extends React.Component<any, AppState> {
}; };
private onCut = (e: ClipboardEvent) => { private onCut = (e: ClipboardEvent) => {
if (isInputLike(e.target) && !isToolIcon(e.target)) { if (isWritableElement(e.target)) {
return; return;
} }
e.clipboardData?.setData( copyToAppClipboard(elements);
"text/plain",
JSON.stringify(
elements
.filter(element => element.isSelected)
.map(({ shape, ...el }) => el),
),
);
elements = deleteSelectedElements(elements); elements = deleteSelectedElements(elements);
this.setState({}); this.setState({});
e.preventDefault(); e.preventDefault();
}; };
private onCopy = (e: ClipboardEvent) => { private onCopy = (e: ClipboardEvent) => {
if (isInputLike(e.target) && !isToolIcon(e.target)) { if (isWritableElement(e.target)) {
return; return;
} }
e.clipboardData?.setData( copyToAppClipboard(elements);
"text/plain",
JSON.stringify(
elements
.filter(element => element.isSelected)
.map(({ shape, ...el }) => el),
),
);
e.preventDefault(); e.preventDefault();
}; };
private onPaste = (e: ClipboardEvent) => { private onPaste = (e: ClipboardEvent) => {
if (isInputLike(e.target) && !isToolIcon(e.target)) { // #686
return; 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 = () => { private onUnload = () => {
@ -452,24 +477,14 @@ export class App extends React.Component<any, AppState> {
private removeWheelEventListener: (() => void) | undefined; private removeWheelEventListener: (() => void) | undefined;
private copyToClipboard = () => { private copyToAppClipboard = () => {
const text = JSON.stringify( copyToAppClipboard(elements);
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 pasteFromClipboard = () => { private pasteFromClipboard = () => {
if ("clipboard" in navigator && "readText" in navigator.clipboard) { const data = getAppClipboard();
navigator.clipboard if (data.elements) {
.readText() this.addElementsFromPaste(data.elements);
.then(text => this.addElementsFromPaste(text));
} }
}; };
@ -809,7 +824,7 @@ export class App extends React.Component<any, AppState> {
options: [ options: [
navigator.clipboard && { navigator.clipboard && {
label: t("labels.copy"), label: t("labels.copy"),
action: this.copyToClipboard, action: this.copyToAppClipboard,
}, },
navigator.clipboard && { navigator.clipboard && {
label: t("labels.paste"), label: t("labels.paste"),
@ -1835,45 +1850,34 @@ export class App extends React.Component<any, AppState> {
}); });
}; };
private addElementsFromPaste = (paste: string) => { private addElementsFromPaste = (
let parsedElements; clipboardElements: readonly ExcalidrawElement[],
try { ) => {
parsedElements = JSON.parse(paste); elements = clearSelection(elements);
} catch (e) {}
if (
Array.isArray(parsedElements) &&
parsedElements.length > 0 &&
parsedElements[0].type // need to implement a better check here...
) {
elements = clearSelection(elements);
const [minX, minY, maxX, maxY] = getCommonBounds(parsedElements); const [minX, minY, maxX, maxY] = getCommonBounds(clipboardElements);
const elementsCenterX = distance(minX, maxX) / 2; const elementsCenterX = distance(minX, maxX) / 2;
const elementsCenterY = distance(minY, maxY) / 2; const elementsCenterY = distance(minY, maxY) / 2;
const dx = const dx =
cursorX - cursorX -
this.state.scrollX - this.state.scrollX -
CANVAS_WINDOW_OFFSET_LEFT - CANVAS_WINDOW_OFFSET_LEFT -
elementsCenterX; elementsCenterX;
const dy = const dy =
cursorY - cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY;
this.state.scrollY -
CANVAS_WINDOW_OFFSET_TOP -
elementsCenterY;
elements = [ elements = [
...elements, ...elements,
...parsedElements.map(parsedElement => { ...clipboardElements.map(clipboardElements => {
const duplicate = duplicateElement(parsedElement); const duplicate = duplicateElement(clipboardElements);
duplicate.x += dx - minX; duplicate.x += dx - minX;
duplicate.y += dy - minY; duplicate.y += dy - minY;
return duplicate; return duplicate;
}), }),
]; ];
this.setState({}); this.setState({});
}
}; };
private getTextWysiwygSnappedToCenterPosition(x: number, y: number) { private getTextWysiwygSnappedToCenterPosition(x: number, y: number) {

View File

@ -11,6 +11,10 @@ import { getCommonBounds, normalizeDimensions } from "../element";
import { Point } from "roughjs/bin/geometry"; import { Point } from "roughjs/bin/geometry";
import { t } from "../i18n"; import { t } from "../i18n";
import {
copyTextToSystemClipboard,
copyCanvasToClipboardAsPng,
} from "../clipboard";
const LOCAL_STORAGE_KEY = "excalidraw"; const LOCAL_STORAGE_KEY = "excalidraw";
const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes"; const LOCAL_STORAGE_SCENE_PREVIOUS_KEY = "excalidraw-previos-scenes";
@ -150,12 +154,16 @@ export async function exportToBackend(
const url = new URL(window.location.href); const url = new URL(window.location.href);
url.searchParams.append("id", json.id); url.searchParams.append("id", json.id);
await navigator.clipboard.writeText(url.toString()); try {
window.alert( await copyTextToSystemClipboard(url.toString());
t("alerts.copiedToClipboard", { window.alert(
url: url.toString(), t("alerts.copiedToClipboard", {
}), url: url.toString(),
); }),
);
} catch (err) {
// TODO: link will be displayed for user to copy manually in later PR
}
} else { } else {
window.alert(t("alerts.couldNotCreateShareableLink")); window.alert(t("alerts.couldNotCreateShareableLink"));
} }
@ -241,19 +249,10 @@ export async function exportCanvas(
} }
}); });
} else if (type === "clipboard") { } else if (type === "clipboard") {
const errorMsg = t("alerts.couldNotCopyToClipboard");
try { try {
tempCanvas.toBlob(async function(blob: any) { copyCanvasToClipboardAsPng(tempCanvas);
try {
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
} catch (err) {
window.alert(errorMsg);
}
});
} catch (err) { } catch (err) {
window.alert(errorMsg); window.alert(t("alerts.couldNotCopyToClipboard"));
} }
} else if (type === "backend") { } else if (type === "backend") {
const appState = getDefaultAppState(); const appState = getDefaultAppState();

View File

@ -28,6 +28,7 @@ export function isInputLike(
| HTMLInputElement | HTMLInputElement
| HTMLTextAreaElement | HTMLTextAreaElement
| HTMLSelectElement | HTMLSelectElement
| HTMLBRElement
| HTMLDivElement { | HTMLDivElement {
return ( return (
(target instanceof HTMLElement && target.dataset.type === "wysiwyg") || (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 // https://github.com/grassator/canvas-text-editor/blob/master/lib/FontMetrics.js
export function measureText(text: string, font: string) { export function measureText(text: string, font: string) {
const line = document.createElement("div"); const line = document.createElement("div");