rewrite clipboard handling (#689)
This commit is contained in:
parent
dab35c9033
commit
954d805cb3
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";
|
||||
|
||||
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 && (
|
||||
<ToolButton
|
||||
type="button"
|
||||
icon={clipboard}
|
||||
|
118
src/index.tsx
118
src/index.tsx
@ -45,11 +45,11 @@ import { ExcalidrawElement } from "./element/types";
|
||||
|
||||
import {
|
||||
isInputLike,
|
||||
isWritableElement,
|
||||
debounce,
|
||||
capitalizeString,
|
||||
distance,
|
||||
distance2d,
|
||||
isToolIcon,
|
||||
} from "./utils";
|
||||
import { KEYS, isArrowKey } from "./keys";
|
||||
|
||||
@ -100,6 +100,12 @@ import { t, languages, setLanguage, getLanguage } from "./i18n";
|
||||
import { StoredScenesList } from "./components/StoredScenesList";
|
||||
import { HintViewer } from "./components/HintViewer";
|
||||
|
||||
import {
|
||||
getAppClipboard,
|
||||
copyToAppClipboard,
|
||||
parseClipboardEvent,
|
||||
} from "./clipboard";
|
||||
|
||||
let { elements } = createScene();
|
||||
const { history } = createHistory();
|
||||
|
||||
@ -222,42 +228,61 @@ export class App extends React.Component<any, AppState> {
|
||||
};
|
||||
|
||||
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({});
|
||||
}
|
||||
const paste = e.clipboardData?.getData("text") || "";
|
||||
this.addElementsFromPaste(paste);
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
private onUnload = () => {
|
||||
@ -452,24 +477,14 @@ export class App extends React.Component<any, AppState> {
|
||||
|
||||
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<any, AppState> {
|
||||
options: [
|
||||
navigator.clipboard && {
|
||||
label: t("labels.copy"),
|
||||
action: this.copyToClipboard,
|
||||
action: this.copyToAppClipboard,
|
||||
},
|
||||
navigator.clipboard && {
|
||||
label: t("labels.paste"),
|
||||
@ -1835,19 +1850,12 @@ export class App extends React.Component<any, AppState> {
|
||||
});
|
||||
};
|
||||
|
||||
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...
|
||||
) {
|
||||
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;
|
||||
@ -1858,22 +1866,18 @@ export class App extends React.Component<any, AppState> {
|
||||
CANVAS_WINDOW_OFFSET_LEFT -
|
||||
elementsCenterX;
|
||||
const dy =
|
||||
cursorY -
|
||||
this.state.scrollY -
|
||||
CANVAS_WINDOW_OFFSET_TOP -
|
||||
elementsCenterY;
|
||||
cursorY - this.state.scrollY - CANVAS_WINDOW_OFFSET_TOP - elementsCenterY;
|
||||
|
||||
elements = [
|
||||
...elements,
|
||||
...parsedElements.map(parsedElement => {
|
||||
const duplicate = duplicateElement(parsedElement);
|
||||
...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) {
|
||||
|
@ -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());
|
||||
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 }),
|
||||
]);
|
||||
copyCanvasToClipboardAsPng(tempCanvas);
|
||||
} catch (err) {
|
||||
window.alert(errorMsg);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
window.alert(errorMsg);
|
||||
window.alert(t("alerts.couldNotCopyToClipboard"));
|
||||
}
|
||||
} else if (type === "backend") {
|
||||
const appState = getDefaultAppState();
|
||||
|
17
src/utils.ts
17
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");
|
||||
|
Loading…
x
Reference in New Issue
Block a user