2020-04-08 09:49:52 -07:00
|
|
|
import {
|
|
|
|
ExcalidrawElement,
|
|
|
|
NonDeletedExcalidrawElement,
|
|
|
|
} from "./element/types";
|
2020-02-16 22:54:50 +01:00
|
|
|
import { getSelectedElements } from "./scene";
|
2020-03-08 10:20:55 -07:00
|
|
|
import { AppState } from "./types";
|
2020-04-06 23:02:17 +02:00
|
|
|
import { SVG_EXPORT_TAG } from "./scene/export";
|
2020-12-11 13:13:23 +02:00
|
|
|
import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
|
2020-10-28 20:52:53 +01:00
|
|
|
import { canvasToBlob } from "./data/blob";
|
2021-03-20 20:20:47 +01:00
|
|
|
import { EXPORT_DATA_TYPES } from "./constants";
|
2020-09-04 14:58:32 +02:00
|
|
|
|
|
|
|
type ElementsClipboard = {
|
2021-03-20 20:20:47 +01:00
|
|
|
type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
|
2020-09-04 14:58:32 +02:00
|
|
|
elements: ExcalidrawElement[];
|
|
|
|
};
|
2020-02-04 11:50:18 +01:00
|
|
|
|
2021-04-09 16:49:58 +02:00
|
|
|
export interface ClipboardData {
|
|
|
|
spreadsheet?: Spreadsheet;
|
|
|
|
elements?: readonly ExcalidrawElement[];
|
|
|
|
text?: string;
|
|
|
|
errorMessage?: string;
|
|
|
|
}
|
|
|
|
|
2020-02-04 11:50:18 +01:00
|
|
|
let CLIPBOARD = "";
|
|
|
|
let PREFER_APP_CLIPBOARD = false;
|
|
|
|
|
2020-02-07 18:42:24 +01:00
|
|
|
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;
|
|
|
|
|
2021-03-20 20:20:47 +01:00
|
|
|
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 (
|
2020-04-08 09:49:52 -07:00
|
|
|
elements: readonly NonDeletedExcalidrawElement[],
|
2020-03-08 10:20:55 -07:00
|
|
|
appState: AppState,
|
2020-05-20 16:21:37 +03:00
|
|
|
) => {
|
2020-09-04 14:58:32 +02:00
|
|
|
const contents: ElementsClipboard = {
|
2021-03-20 20:20:47 +01:00
|
|
|
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);
|
2020-11-05 19:06:18 +02:00
|
|
|
} catch (error) {
|
2020-02-04 11:50:18 +01:00
|
|
|
PREFER_APP_CLIPBOARD = true;
|
2020-11-05 19:06:18 +02:00
|
|
|
console.error(error);
|
2020-02-04 11:50:18 +01:00
|
|
|
}
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|
2020-02-04 11:50:18 +01:00
|
|
|
|
2020-09-04 14:58:32 +02:00
|
|
|
const getAppClipboard = (): Partial<ElementsClipboard> => {
|
2020-04-06 23:02:17 +02:00
|
|
|
if (!CLIPBOARD) {
|
|
|
|
return {};
|
|
|
|
}
|
|
|
|
|
2020-02-04 11:50:18 +01:00
|
|
|
try {
|
2020-09-04 14:58:32 +02:00
|
|
|
return JSON.parse(CLIPBOARD);
|
2020-02-28 23:03:53 +01:00
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
2020-09-04 14:58:32 +02:00
|
|
|
return {};
|
2020-02-07 18:42:24 +01:00
|
|
|
}
|
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-05-20 16:21:37 +03:00
|
|
|
};
|
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 (
|
2020-02-28 23:03:53 +01:00
|
|
|
event: ClipboardEvent | null,
|
2020-09-04 14:58:32 +02:00
|
|
|
): Promise<string> => {
|
2020-02-04 11:50:18 +01:00
|
|
|
try {
|
2020-02-28 23:03:53 +01:00
|
|
|
const text = event
|
|
|
|
? event.clipboardData?.getData("text/plain").trim()
|
2020-02-07 18:42:24 +01:00
|
|
|
: probablySupportsClipboardReadText &&
|
|
|
|
(await navigator.clipboard.readText());
|
|
|
|
|
2020-09-04 14:58:32 +02:00
|
|
|
return text || "";
|
|
|
|
} catch {
|
|
|
|
return "";
|
2020-02-07 18:42:24 +01:00
|
|
|
}
|
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,
|
2021-04-09 16:49:58 +02:00
|
|
|
): Promise<ClipboardData> => {
|
2020-09-04 14:58:32 +02:00
|
|
|
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
|
2020-11-05 19:06:18 +02:00
|
|
|
// 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);
|
2021-03-20 20:20:47 +01:00
|
|
|
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
|
2020-11-05 19:06:18 +02:00
|
|
|
// 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-05-20 16:21:37 +03:00
|
|
|
};
|
2020-02-04 11:50:18 +01:00
|
|
|
|
2020-10-28 20:52:53 +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
|
|
|
|
2020-05-20 16:21:37 +03: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
|
2020-11-05 19:06:18 +02:00
|
|
|
// not focused
|
2020-02-04 11:50:18 +01:00
|
|
|
await navigator.clipboard.writeText(text || "");
|
|
|
|
copied = true;
|
2020-03-07 10:20:38 -05:00
|
|
|
} 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
|
2020-11-05 19:06:18 +02:00
|
|
|
// 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-05-20 16:21:37 +03:00
|
|
|
};
|
2020-02-04 11:50:18 +01:00
|
|
|
|
|
|
|
// adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
|
2020-05-20 16:21:37 +03:00
|
|
|
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");
|
2020-03-07 10:20:38 -05:00
|
|
|
} catch (error) {
|
|
|
|
console.error(error);
|
|
|
|
}
|
2020-02-04 11:50:18 +01:00
|
|
|
|
|
|
|
textarea.remove();
|
|
|
|
|
|
|
|
return success;
|
2020-05-20 16:21:37 +03:00
|
|
|
};
|