123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521 |
- import {
- ALLOWED_PASTE_MIME_TYPES,
- EXPORT_DATA_TYPES,
- MIME_TYPES,
- arrayToMap,
- isMemberOf,
- isPromiseLike,
- } from "@excalidraw/common";
- import { mutateElement } from "@excalidraw/element";
- import { deepCopyElement } from "@excalidraw/element";
- import {
- isFrameLikeElement,
- isInitializedImageElement,
- } from "@excalidraw/element";
- import { getContainingFrame } from "@excalidraw/element";
- import type {
- ExcalidrawElement,
- NonDeletedExcalidrawElement,
- } from "@excalidraw/element/types";
- import { ExcalidrawError } from "./errors";
- import { createFile, isSupportedImageFileType } from "./data/blob";
- import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
- import type { Spreadsheet } from "./charts";
- import type { BinaryFiles } from "./types";
- type ElementsClipboard = {
- type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
- elements: readonly NonDeletedExcalidrawElement[];
- files: BinaryFiles | undefined;
- };
- export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
- export interface ClipboardData {
- spreadsheet?: Spreadsheet;
- elements?: readonly ExcalidrawElement[];
- files?: BinaryFiles;
- text?: string;
- mixedContent?: PastedMixedContent;
- errorMessage?: string;
- programmaticAPI?: boolean;
- }
- type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
- type ParsedClipboardEventTextData =
- | { type: "text"; value: string }
- | { type: "mixedContent"; value: PastedMixedContent };
- export const probablySupportsClipboardReadText =
- "clipboard" in navigator && "readText" in navigator.clipboard;
- 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[]; files?: BinaryFiles } => {
- if (
- [
- EXPORT_DATA_TYPES.excalidraw,
- EXPORT_DATA_TYPES.excalidrawClipboard,
- EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
- ].includes(contents?.type) &&
- Array.isArray(contents.elements)
- ) {
- return true;
- }
- return false;
- };
- export const createPasteEvent = ({
- types,
- files,
- }: {
- types?: { [key in AllowedPasteMimeTypes]?: string | File };
- files?: File[];
- }) => {
- if (!types && !files) {
- console.warn("createPasteEvent: no types or files provided");
- }
- const event = new ClipboardEvent("paste", {
- clipboardData: new DataTransfer(),
- });
- if (types) {
- for (const [type, value] of Object.entries(types)) {
- if (typeof value !== "string") {
- files = files || [];
- files.push(value);
- continue;
- }
- try {
- event.clipboardData?.setData(type, value);
- if (event.clipboardData?.getData(type) !== value) {
- throw new Error(`Failed to set "${type}" as clipboardData item`);
- }
- } catch (error: any) {
- throw new Error(error.message);
- }
- }
- }
- if (files) {
- let idx = -1;
- for (const file of files) {
- idx++;
- try {
- event.clipboardData?.items.add(file);
- if (event.clipboardData?.files[idx] !== file) {
- throw new Error(
- `Failed to set file "${file.name}" as clipboardData item`,
- );
- }
- } catch (error: any) {
- throw new Error(error.message);
- }
- }
- }
- return event;
- };
- export const serializeAsClipboardJSON = ({
- elements,
- files,
- }: {
- elements: readonly NonDeletedExcalidrawElement[];
- files: BinaryFiles | null;
- }) => {
- const elementsMap = arrayToMap(elements);
- const framesToCopy = new Set(
- elements.filter((element) => isFrameLikeElement(element)),
- );
- let foundFile = false;
- const _files = elements.reduce((acc, element) => {
- if (isInitializedImageElement(element)) {
- foundFile = true;
- if (files && files[element.fileId]) {
- acc[element.fileId] = files[element.fileId];
- }
- }
- return acc;
- }, {} as BinaryFiles);
- if (foundFile && !files) {
- console.warn(
- "copyToClipboard: attempting to file element(s) without providing associated `files` object.",
- );
- }
- // select bound text elements when copying
- const contents: ElementsClipboard = {
- type: EXPORT_DATA_TYPES.excalidrawClipboard,
- elements: elements.map((element) => {
- if (
- getContainingFrame(element, elementsMap) &&
- !framesToCopy.has(getContainingFrame(element, elementsMap)!)
- ) {
- const copiedElement = deepCopyElement(element);
- mutateElement(copiedElement, elementsMap, {
- frameId: null,
- });
- return copiedElement;
- }
- return element;
- }),
- files: files ? _files : undefined,
- };
- return JSON.stringify(contents);
- };
- export const copyToClipboard = async (
- elements: readonly NonDeletedExcalidrawElement[],
- files: BinaryFiles | null,
- /** supply if available to make the operation more certain to succeed */
- clipboardEvent?: ClipboardEvent | null,
- ) => {
- await copyTextToSystemClipboard(
- serializeAsClipboardJSON({ elements, files }),
- clipboardEvent,
- );
- };
- const parsePotentialSpreadsheet = (
- text: string,
- ): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
- const result = tryParseSpreadsheet(text);
- if (result.type === VALID_SPREADSHEET) {
- return { spreadsheet: result.spreadsheet };
- }
- return null;
- };
- /** internal, specific to parsing paste events. Do not reuse. */
- function parseHTMLTree(el: ChildNode) {
- let result: PastedMixedContent = [];
- for (const node of el.childNodes) {
- if (node.nodeType === 3) {
- const text = node.textContent?.trim();
- if (text) {
- result.push({ type: "text", value: text });
- }
- } else if (node instanceof HTMLImageElement) {
- const url = node.getAttribute("src");
- if (url && url.startsWith("http")) {
- result.push({ type: "imageUrl", value: url });
- }
- } else {
- result = result.concat(parseHTMLTree(node));
- }
- }
- return result;
- }
- const maybeParseHTMLPaste = (
- event: ClipboardEvent,
- ): { type: "mixedContent"; value: PastedMixedContent } | null => {
- const html = event.clipboardData?.getData(MIME_TYPES.html);
- if (!html) {
- return null;
- }
- try {
- const doc = new DOMParser().parseFromString(html, MIME_TYPES.html);
- const content = parseHTMLTree(doc.body);
- if (content.length) {
- return { type: "mixedContent", value: content };
- }
- } catch (error: any) {
- console.error(`error in parseHTMLFromPaste: ${error.message}`);
- }
- return null;
- };
- /**
- * Reads OS clipboard programmatically. May not work on all browsers.
- * Will prompt user for permission if not granted.
- */
- export const readSystemClipboard = async () => {
- const types: { [key in AllowedPasteMimeTypes]?: string | File } = {};
- let clipboardItems: ClipboardItems;
- try {
- clipboardItems = await navigator.clipboard?.read();
- } catch (error: any) {
- try {
- if (navigator.clipboard?.readText) {
- console.warn(
- `navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
- );
- const readText = await navigator.clipboard?.readText();
- if (readText) {
- return { [MIME_TYPES.text]: readText };
- }
- }
- } catch (error: any) {
- // @ts-ignore
- if (navigator.clipboard?.read) {
- console.warn(
- `navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
- );
- } else {
- if (error.name === "DataError") {
- console.warn(
- `navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
- );
- return types;
- }
- throw error;
- }
- }
- throw error;
- }
- for (const item of clipboardItems) {
- for (const type of item.types) {
- if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
- continue;
- }
- try {
- if (type === MIME_TYPES.text || type === MIME_TYPES.html) {
- types[type] = await (await item.getType(type)).text();
- } else if (isSupportedImageFileType(type)) {
- const imageBlob = await item.getType(type);
- const file = createFile(imageBlob, type, undefined);
- types[type] = file;
- } else {
- throw new ExcalidrawError(`Unsupported clipboard type: ${type}`);
- }
- } catch (error: any) {
- console.warn(
- error instanceof ExcalidrawError
- ? error.message
- : `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
- );
- }
- }
- }
- if (Object.keys(types).length === 0) {
- console.warn("No clipboard data found from clipboard.read().");
- return types;
- }
- return types;
- };
- /**
- * Parses "paste" ClipboardEvent.
- */
- const parseClipboardEventTextData = async (
- event: ClipboardEvent,
- isPlainPaste = false,
- ): Promise<ParsedClipboardEventTextData> => {
- try {
- const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
- if (mixedContent) {
- if (mixedContent.value.every((item) => item.type === "text")) {
- return {
- type: "text",
- value:
- event.clipboardData?.getData(MIME_TYPES.text) ||
- mixedContent.value
- .map((item) => item.value)
- .join("\n")
- .trim(),
- };
- }
- return mixedContent;
- }
- const text = event.clipboardData?.getData(MIME_TYPES.text);
- return { type: "text", value: (text || "").trim() };
- } catch {
- return { type: "text", value: "" };
- }
- };
- /**
- * Attempts to parse clipboard event.
- */
- export const parseClipboard = async (
- event: ClipboardEvent,
- isPlainPaste = false,
- ): Promise<ClipboardData> => {
- const parsedEventData = await parseClipboardEventTextData(
- event,
- isPlainPaste,
- );
- if (parsedEventData.type === "mixedContent") {
- return {
- mixedContent: parsedEventData.value,
- };
- }
- try {
- // if system clipboard contains spreadsheet, use it even though it's
- // technically possible it's staler than in-app clipboard
- const spreadsheetResult =
- !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
- if (spreadsheetResult) {
- return spreadsheetResult;
- }
- } catch (error: any) {
- console.error(error);
- }
- try {
- const systemClipboardData = JSON.parse(parsedEventData.value);
- const programmaticAPI =
- systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
- if (clipboardContainsElements(systemClipboardData)) {
- return {
- elements: systemClipboardData.elements,
- files: systemClipboardData.files,
- text: isPlainPaste
- ? JSON.stringify(systemClipboardData.elements, null, 2)
- : undefined,
- programmaticAPI,
- };
- }
- } catch {}
- return { text: parsedEventData.value };
- };
- export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
- try {
- // in Safari so far we need to construct the ClipboardItem synchronously
- // (i.e. in the same tick) otherwise browser will complain for lack of
- // user intent. Using a Promise ClipboardItem constructor solves this.
- // https://bugs.webkit.org/show_bug.cgi?id=222262
- //
- // Note that Firefox (and potentially others) seems to support Promise
- // ClipboardItem constructor, but throws on an unrelated MIME type error.
- // So we need to await this and fallback to awaiting the blob if applicable.
- await navigator.clipboard.write([
- new window.ClipboardItem({
- [MIME_TYPES.png]: blob,
- }),
- ]);
- } catch (error: any) {
- // if we're using a Promise ClipboardItem, let's try constructing
- // with resolution value instead
- if (isPromiseLike(blob)) {
- await navigator.clipboard.write([
- new window.ClipboardItem({
- [MIME_TYPES.png]: await blob,
- }),
- ]);
- } else {
- throw error;
- }
- }
- };
- export const copyTextToSystemClipboard = async (
- text: string | null,
- clipboardEvent?: ClipboardEvent | null,
- ) => {
- // (1) first try using Async Clipboard API
- if (probablySupportsClipboardWriteText) {
- try {
- // NOTE: doesn't work on FF on non-HTTPS domains, or when document
- // not focused
- await navigator.clipboard.writeText(text || "");
- return;
- } catch (error: any) {
- console.error(error);
- }
- }
- // (2) if fails and we have access to ClipboardEvent, use plain old setData()
- try {
- if (clipboardEvent) {
- clipboardEvent.clipboardData?.setData(MIME_TYPES.text, text || "");
- if (clipboardEvent.clipboardData?.getData(MIME_TYPES.text) !== text) {
- throw new Error("Failed to setData on clipboardEvent");
- }
- return;
- }
- } catch (error: any) {
- console.error(error);
- }
- // (3) if that fails, use document.execCommand
- if (!copyTextViaExecCommand(text)) {
- throw new Error("Error copying to clipboard.");
- }
- };
- // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
- const copyTextViaExecCommand = (text: string | null) => {
- // 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 (!text) {
- text = " ";
- }
- 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: any) {
- console.error(error);
- }
- textarea.remove();
- return success;
- };
|