clipboard.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. import {
  2. ExcalidrawElement,
  3. NonDeletedExcalidrawElement,
  4. } from "./element/types";
  5. import { AppState, BinaryFiles } from "./types";
  6. import { SVG_EXPORT_TAG } from "./scene/export";
  7. import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
  8. import { EXPORT_DATA_TYPES, MIME_TYPES } from "./constants";
  9. import { isInitializedImageElement } from "./element/typeChecks";
  10. import { isPromiseLike } from "./utils";
  11. type ElementsClipboard = {
  12. type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
  13. elements: readonly NonDeletedExcalidrawElement[];
  14. files: BinaryFiles | undefined;
  15. };
  16. export interface ClipboardData {
  17. spreadsheet?: Spreadsheet;
  18. elements?: readonly ExcalidrawElement[];
  19. files?: BinaryFiles;
  20. text?: string;
  21. errorMessage?: string;
  22. }
  23. let CLIPBOARD = "";
  24. let PREFER_APP_CLIPBOARD = false;
  25. export const probablySupportsClipboardReadText =
  26. "clipboard" in navigator && "readText" in navigator.clipboard;
  27. export const probablySupportsClipboardWriteText =
  28. "clipboard" in navigator && "writeText" in navigator.clipboard;
  29. export const probablySupportsClipboardBlob =
  30. "clipboard" in navigator &&
  31. "write" in navigator.clipboard &&
  32. "ClipboardItem" in window &&
  33. "toBlob" in HTMLCanvasElement.prototype;
  34. const clipboardContainsElements = (
  35. contents: any,
  36. ): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
  37. if (
  38. [
  39. EXPORT_DATA_TYPES.excalidraw,
  40. EXPORT_DATA_TYPES.excalidrawClipboard,
  41. ].includes(contents?.type) &&
  42. Array.isArray(contents.elements)
  43. ) {
  44. return true;
  45. }
  46. return false;
  47. };
  48. export const copyToClipboard = async (
  49. elements: readonly NonDeletedExcalidrawElement[],
  50. appState: AppState,
  51. files: BinaryFiles | null,
  52. ) => {
  53. // select binded text elements when copying
  54. const contents: ElementsClipboard = {
  55. type: EXPORT_DATA_TYPES.excalidrawClipboard,
  56. elements,
  57. files: files
  58. ? elements.reduce((acc, element) => {
  59. if (isInitializedImageElement(element) && files[element.fileId]) {
  60. acc[element.fileId] = files[element.fileId];
  61. }
  62. return acc;
  63. }, {} as BinaryFiles)
  64. : undefined,
  65. };
  66. const json = JSON.stringify(contents);
  67. CLIPBOARD = json;
  68. try {
  69. PREFER_APP_CLIPBOARD = false;
  70. await copyTextToSystemClipboard(json);
  71. } catch (error: any) {
  72. PREFER_APP_CLIPBOARD = true;
  73. console.error(error);
  74. }
  75. };
  76. const getAppClipboard = (): Partial<ElementsClipboard> => {
  77. if (!CLIPBOARD) {
  78. return {};
  79. }
  80. try {
  81. return JSON.parse(CLIPBOARD);
  82. } catch (error: any) {
  83. console.error(error);
  84. return {};
  85. }
  86. };
  87. const parsePotentialSpreadsheet = (
  88. text: string,
  89. ): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
  90. const result = tryParseSpreadsheet(text);
  91. if (result.type === VALID_SPREADSHEET) {
  92. return { spreadsheet: result.spreadsheet };
  93. }
  94. return null;
  95. };
  96. /**
  97. * Retrieves content from system clipboard (either from ClipboardEvent or
  98. * via async clipboard API if supported)
  99. */
  100. export const getSystemClipboard = async (
  101. event: ClipboardEvent | null,
  102. ): Promise<string> => {
  103. try {
  104. const text = event
  105. ? event.clipboardData?.getData("text/plain")
  106. : probablySupportsClipboardReadText &&
  107. (await navigator.clipboard.readText());
  108. return (text || "").trim();
  109. } catch {
  110. return "";
  111. }
  112. };
  113. /**
  114. * Attempts to parse clipboard. Prefers system clipboard.
  115. */
  116. export const parseClipboard = async (
  117. event: ClipboardEvent | null,
  118. isPlainPaste = false,
  119. ): Promise<ClipboardData> => {
  120. const systemClipboard = await getSystemClipboard(event);
  121. // if system clipboard empty, couldn't be resolved, or contains previously
  122. // copied excalidraw scene as SVG, fall back to previously copied excalidraw
  123. // elements
  124. if (
  125. !systemClipboard ||
  126. (!isPlainPaste && systemClipboard.includes(SVG_EXPORT_TAG))
  127. ) {
  128. return getAppClipboard();
  129. }
  130. // if system clipboard contains spreadsheet, use it even though it's
  131. // technically possible it's staler than in-app clipboard
  132. const spreadsheetResult =
  133. !isPlainPaste && parsePotentialSpreadsheet(systemClipboard);
  134. if (spreadsheetResult) {
  135. return spreadsheetResult;
  136. }
  137. const appClipboardData = getAppClipboard();
  138. try {
  139. const systemClipboardData = JSON.parse(systemClipboard);
  140. if (clipboardContainsElements(systemClipboardData)) {
  141. return {
  142. elements: systemClipboardData.elements,
  143. files: systemClipboardData.files,
  144. text: isPlainPaste
  145. ? JSON.stringify(systemClipboardData.elements, null, 2)
  146. : undefined,
  147. };
  148. }
  149. } catch (e) {}
  150. // system clipboard doesn't contain excalidraw elements → return plaintext
  151. // unless we set a flag to prefer in-app clipboard because browser didn't
  152. // support storing to system clipboard on copy
  153. return PREFER_APP_CLIPBOARD && appClipboardData.elements
  154. ? {
  155. ...appClipboardData,
  156. text: isPlainPaste
  157. ? JSON.stringify(appClipboardData.elements, null, 2)
  158. : undefined,
  159. }
  160. : { text: systemClipboard };
  161. };
  162. export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
  163. try {
  164. // in Safari so far we need to construct the ClipboardItem synchronously
  165. // (i.e. in the same tick) otherwise browser will complain for lack of
  166. // user intent. Using a Promise ClipboardItem constructor solves this.
  167. // https://bugs.webkit.org/show_bug.cgi?id=222262
  168. //
  169. // Note that Firefox (and potentially others) seems to support Promise
  170. // ClipboardItem constructor, but throws on an unrelated MIME type error.
  171. // So we need to await this and fallback to awaiting the blob if applicable.
  172. await navigator.clipboard.write([
  173. new window.ClipboardItem({
  174. [MIME_TYPES.png]: blob,
  175. }),
  176. ]);
  177. } catch (error: any) {
  178. // if we're using a Promise ClipboardItem, let's try constructing
  179. // with resolution value instead
  180. if (isPromiseLike(blob)) {
  181. await navigator.clipboard.write([
  182. new window.ClipboardItem({
  183. [MIME_TYPES.png]: await blob,
  184. }),
  185. ]);
  186. } else {
  187. throw error;
  188. }
  189. }
  190. };
  191. export const copyTextToSystemClipboard = async (text: string | null) => {
  192. let copied = false;
  193. if (probablySupportsClipboardWriteText) {
  194. try {
  195. // NOTE: doesn't work on FF on non-HTTPS domains, or when document
  196. // not focused
  197. await navigator.clipboard.writeText(text || "");
  198. copied = true;
  199. } catch (error: any) {
  200. console.error(error);
  201. }
  202. }
  203. // Note that execCommand doesn't allow copying empty strings, so if we're
  204. // clearing clipboard using this API, we must copy at least an empty char
  205. if (!copied && !copyTextViaExecCommand(text || " ")) {
  206. throw new Error("couldn't copy");
  207. }
  208. };
  209. // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
  210. const copyTextViaExecCommand = (text: string) => {
  211. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  212. const textarea = document.createElement("textarea");
  213. textarea.style.border = "0";
  214. textarea.style.padding = "0";
  215. textarea.style.margin = "0";
  216. textarea.style.position = "absolute";
  217. textarea.style[isRTL ? "right" : "left"] = "-9999px";
  218. const yPosition = window.pageYOffset || document.documentElement.scrollTop;
  219. textarea.style.top = `${yPosition}px`;
  220. // Prevent zooming on iOS
  221. textarea.style.fontSize = "12pt";
  222. textarea.setAttribute("readonly", "");
  223. textarea.value = text;
  224. document.body.appendChild(textarea);
  225. let success = false;
  226. try {
  227. textarea.select();
  228. textarea.setSelectionRange(0, textarea.value.length);
  229. success = document.execCommand("copy");
  230. } catch (error: any) {
  231. console.error(error);
  232. }
  233. textarea.remove();
  234. return success;
  235. };