clipboard.ts 6.1 KB

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