excalidraw/src/clipboard.ts

216 lines
6.0 KiB
TypeScript
Raw Normal View History

import {
ExcalidrawElement,
NonDeletedExcalidrawElement,
} from "./element/types";
import { getSelectedElements } from "./scene";
import { AppState } from "./types";
import { SVG_EXPORT_TAG } from "./scene/export";
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
import { canvasToBlob } from "./data/blob";
import { EXPORT_DATA_TYPES } from "./constants";
2020-09-04 14:58:32 +02:00
type ElementsClipboard = {
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
2020-09-04 14:58:32 +02:00
elements: ExcalidrawElement[];
};
2020-02-04 11:50:18 +01:00
let CLIPBOARD = "";
let PREFER_APP_CLIPBOARD = false;
export const probablySupportsClipboardReadText =
"clipboard" in navigator && "readText" in navigator.clipboard;
2020-02-04 11:50:18 +01:00
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;
const clipboardContainsElements = (
contents: any,
): contents is { elements: ExcalidrawElement[] } => {
if (
[
EXPORT_DATA_TYPES.excalidraw,
EXPORT_DATA_TYPES.excalidrawClipboard,
].includes(contents?.type) &&
Array.isArray(contents.elements)
) {
2020-09-04 14:58:32 +02:00
return true;
}
return false;
};
export const copyToClipboard = async (
elements: readonly NonDeletedExcalidrawElement[],
appState: AppState,
) => {
2020-09-04 14:58:32 +02:00
const contents: ElementsClipboard = {
type: EXPORT_DATA_TYPES.excalidrawClipboard,
2020-09-04 14:58:32 +02:00
elements: getSelectedElements(elements, appState),
};
const json = JSON.stringify(contents);
CLIPBOARD = json;
2020-02-04 11:50:18 +01:00
try {
PREFER_APP_CLIPBOARD = false;
2020-09-04 14:58:32 +02:00
await copyTextToSystemClipboard(json);
} catch (error) {
2020-02-04 11:50:18 +01:00
PREFER_APP_CLIPBOARD = true;
console.error(error);
2020-02-04 11:50:18 +01:00
}
};
2020-02-04 11:50:18 +01:00
2020-09-04 14:58:32 +02:00
const getAppClipboard = (): Partial<ElementsClipboard> => {
if (!CLIPBOARD) {
return {};
}
2020-02-04 11:50:18 +01:00
try {
2020-09-04 14:58:32 +02:00
return JSON.parse(CLIPBOARD);
} catch (error) {
console.error(error);
2020-09-04 14:58:32 +02:00
return {};
}
2020-09-04 14:58:32 +02:00
};
2020-02-04 11:50:18 +01:00
2020-09-04 14:58:32 +02:00
const parsePotentialSpreadsheet = (
text: string,
): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
const result = tryParseSpreadsheet(text);
if (result.type === VALID_SPREADSHEET) {
return { spreadsheet: result.spreadsheet };
}
return null;
};
2020-02-04 11:50:18 +01:00
2020-09-04 14:58:32 +02:00
/**
* Retrieves content from system clipboard (either from ClipboardEvent or
* via async clipboard API if supported)
*/
const getSystemClipboard = async (
event: ClipboardEvent | null,
2020-09-04 14:58:32 +02:00
): Promise<string> => {
2020-02-04 11:50:18 +01:00
try {
const text = event
? event.clipboardData?.getData("text/plain").trim()
: probablySupportsClipboardReadText &&
(await navigator.clipboard.readText());
2020-09-04 14:58:32 +02:00
return text || "";
} catch {
return "";
}
2020-09-04 14:58:32 +02:00
};
2020-02-04 11:50:18 +01:00
2020-09-04 14:58:32 +02:00
/**
* Attemps to parse clipboard. Prefers system clipboard.
*/
export const parseClipboard = async (
event: ClipboardEvent | null,
): Promise<{
spreadsheet?: Spreadsheet;
elements?: readonly ExcalidrawElement[];
text?: string;
errorMessage?: string;
}> => {
const systemClipboard = await getSystemClipboard(event);
// if system clipboard empty, couldn't be resolved, or contains previously
// copied excalidraw scene as SVG, fall back to previously copied excalidraw
// elements
if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
return getAppClipboard();
}
// if system clipboard contains spreadsheet, use it even though it's
// technically possible it's staler than in-app clipboard
2020-09-04 14:58:32 +02:00
const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
if (spreadsheetResult) {
return spreadsheetResult;
}
const appClipboardData = getAppClipboard();
try {
const systemClipboardData = JSON.parse(systemClipboard);
if (clipboardContainsElements(systemClipboardData)) {
2020-09-04 14:58:32 +02:00
return { elements: systemClipboardData.elements };
}
return appClipboardData;
} catch {
// system clipboard doesn't contain excalidraw elements → return plaintext
// unless we set a flag to prefer in-app clipboard because browser didn't
// support storing to system clipboard on copy
2020-09-04 14:58:32 +02:00
return PREFER_APP_CLIPBOARD && appClipboardData.elements
? appClipboardData
: { text: systemClipboard };
}
};
2020-02-04 11:50:18 +01:00
export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
const blob = await canvasToBlob(canvas);
await navigator.clipboard.write([
new window.ClipboardItem({ "image/png": blob }),
]);
};
2020-02-04 11:50:18 +01:00
export const copyTextToSystemClipboard = async (text: string | null) => {
2020-02-04 11:50:18 +01:00
let copied = false;
if (probablySupportsClipboardWriteText) {
try {
// NOTE: doesn't work on FF on non-HTTPS domains, or when document
// not focused
2020-02-04 11:50:18 +01:00
await navigator.clipboard.writeText(text || "");
copied = true;
} catch (error) {
console.error(error);
}
2020-02-04 11:50:18 +01:00
}
// 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
2020-02-04 11:50:18 +01:00
if (!copied && !copyTextViaExecCommand(text || " ")) {
throw new Error("couldn't copy");
}
};
2020-02-04 11:50:18 +01:00
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
const copyTextViaExecCommand = (text: string) => {
2020-02-04 11:50:18 +01:00
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 (error) {
console.error(error);
}
2020-02-04 11:50:18 +01:00
textarea.remove();
return success;
};