rewrite clipboard handling (#689)
This commit is contained in:
parent
dab35c9033
commit
954d805cb3
@ -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
144
src/clipboard.ts
Normal 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;
|
||||||
|
}
|
@ -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}
|
||||||
|
154
src/index.tsx
154
src/index.tsx
@ -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) {
|
||||||
|
@ -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();
|
||||||
|
17
src/utils.ts
17
src/utils.ts
@ -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");
|
||||||
|
Loading…
x
Reference in New Issue
Block a user