clipboard.ts 6.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import {
  2. ExcalidrawElement,
  3. NonDeletedExcalidrawElement,
  4. } from "./element/types";
  5. import { getSelectedElements } from "./scene";
  6. import { AppState } from "./types";
  7. import { SVG_EXPORT_TAG } from "./scene/export";
  8. import { tryParseSpreadsheet, Spreadsheet, VALID_SPREADSHEET } from "./charts";
  9. import { canvasToBlob } from "./data/blob";
  10. import { EXPORT_DATA_TYPES } from "./constants";
  11. type ElementsClipboard = {
  12. type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
  13. elements: ExcalidrawElement[];
  14. };
  15. export interface ClipboardData {
  16. spreadsheet?: Spreadsheet;
  17. elements?: readonly ExcalidrawElement[];
  18. text?: string;
  19. errorMessage?: string;
  20. }
  21. let CLIPBOARD = "";
  22. let PREFER_APP_CLIPBOARD = false;
  23. export const probablySupportsClipboardReadText =
  24. "clipboard" in navigator && "readText" in navigator.clipboard;
  25. export const probablySupportsClipboardWriteText =
  26. "clipboard" in navigator && "writeText" in navigator.clipboard;
  27. export const probablySupportsClipboardBlob =
  28. "clipboard" in navigator &&
  29. "write" in navigator.clipboard &&
  30. "ClipboardItem" in window &&
  31. "toBlob" in HTMLCanvasElement.prototype;
  32. const clipboardContainsElements = (
  33. contents: any,
  34. ): contents is { elements: ExcalidrawElement[] } => {
  35. if (
  36. [
  37. EXPORT_DATA_TYPES.excalidraw,
  38. EXPORT_DATA_TYPES.excalidrawClipboard,
  39. ].includes(contents?.type) &&
  40. Array.isArray(contents.elements)
  41. ) {
  42. return true;
  43. }
  44. return false;
  45. };
  46. export const copyToClipboard = async (
  47. elements: readonly NonDeletedExcalidrawElement[],
  48. appState: AppState,
  49. ) => {
  50. const contents: ElementsClipboard = {
  51. type: EXPORT_DATA_TYPES.excalidrawClipboard,
  52. elements: getSelectedElements(elements, appState),
  53. };
  54. const json = JSON.stringify(contents);
  55. CLIPBOARD = json;
  56. try {
  57. PREFER_APP_CLIPBOARD = false;
  58. await copyTextToSystemClipboard(json);
  59. } catch (error) {
  60. PREFER_APP_CLIPBOARD = true;
  61. console.error(error);
  62. }
  63. };
  64. const getAppClipboard = (): Partial<ElementsClipboard> => {
  65. if (!CLIPBOARD) {
  66. return {};
  67. }
  68. try {
  69. return JSON.parse(CLIPBOARD);
  70. } catch (error) {
  71. console.error(error);
  72. return {};
  73. }
  74. };
  75. const parsePotentialSpreadsheet = (
  76. text: string,
  77. ): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
  78. const result = tryParseSpreadsheet(text);
  79. if (result.type === VALID_SPREADSHEET) {
  80. return { spreadsheet: result.spreadsheet };
  81. }
  82. return null;
  83. };
  84. /**
  85. * Retrieves content from system clipboard (either from ClipboardEvent or
  86. * via async clipboard API if supported)
  87. */
  88. const getSystemClipboard = async (
  89. event: ClipboardEvent | null,
  90. ): Promise<string> => {
  91. try {
  92. const text = event
  93. ? event.clipboardData?.getData("text/plain").trim()
  94. : probablySupportsClipboardReadText &&
  95. (await navigator.clipboard.readText());
  96. return text || "";
  97. } catch {
  98. return "";
  99. }
  100. };
  101. /**
  102. * Attemps to parse clipboard. Prefers system clipboard.
  103. */
  104. export const parseClipboard = async (
  105. event: ClipboardEvent | null,
  106. ): Promise<ClipboardData> => {
  107. const systemClipboard = await getSystemClipboard(event);
  108. // if system clipboard empty, couldn't be resolved, or contains previously
  109. // copied excalidraw scene as SVG, fall back to previously copied excalidraw
  110. // elements
  111. if (!systemClipboard || systemClipboard.includes(SVG_EXPORT_TAG)) {
  112. return getAppClipboard();
  113. }
  114. // if system clipboard contains spreadsheet, use it even though it's
  115. // technically possible it's staler than in-app clipboard
  116. const spreadsheetResult = parsePotentialSpreadsheet(systemClipboard);
  117. if (spreadsheetResult) {
  118. return spreadsheetResult;
  119. }
  120. const appClipboardData = getAppClipboard();
  121. try {
  122. const systemClipboardData = JSON.parse(systemClipboard);
  123. if (clipboardContainsElements(systemClipboardData)) {
  124. return { elements: systemClipboardData.elements };
  125. }
  126. return appClipboardData;
  127. } catch {
  128. // system clipboard doesn't contain excalidraw elements → return plaintext
  129. // unless we set a flag to prefer in-app clipboard because browser didn't
  130. // support storing to system clipboard on copy
  131. return PREFER_APP_CLIPBOARD && appClipboardData.elements
  132. ? appClipboardData
  133. : { text: systemClipboard };
  134. }
  135. };
  136. export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) => {
  137. const blob = await canvasToBlob(canvas);
  138. await navigator.clipboard.write([
  139. new window.ClipboardItem({ "image/png": blob }),
  140. ]);
  141. };
  142. export const copyTextToSystemClipboard = async (text: string | null) => {
  143. let copied = false;
  144. if (probablySupportsClipboardWriteText) {
  145. try {
  146. // NOTE: doesn't work on FF on non-HTTPS domains, or when document
  147. // not focused
  148. await navigator.clipboard.writeText(text || "");
  149. copied = true;
  150. } catch (error) {
  151. console.error(error);
  152. }
  153. }
  154. // Note that execCommand doesn't allow copying empty strings, so if we're
  155. // clearing clipboard using this API, we must copy at least an empty char
  156. if (!copied && !copyTextViaExecCommand(text || " ")) {
  157. throw new Error("couldn't copy");
  158. }
  159. };
  160. // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
  161. const copyTextViaExecCommand = (text: string) => {
  162. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  163. const textarea = document.createElement("textarea");
  164. textarea.style.border = "0";
  165. textarea.style.padding = "0";
  166. textarea.style.margin = "0";
  167. textarea.style.position = "absolute";
  168. textarea.style[isRTL ? "right" : "left"] = "-9999px";
  169. const yPosition = window.pageYOffset || document.documentElement.scrollTop;
  170. textarea.style.top = `${yPosition}px`;
  171. // Prevent zooming on iOS
  172. textarea.style.fontSize = "12pt";
  173. textarea.setAttribute("readonly", "");
  174. textarea.value = text;
  175. document.body.appendChild(textarea);
  176. let success = false;
  177. try {
  178. textarea.select();
  179. textarea.setSelectionRange(0, textarea.value.length);
  180. success = document.execCommand("copy");
  181. } catch (error) {
  182. console.error(error);
  183. }
  184. textarea.remove();
  185. return success;
  186. };