clipboard.ts 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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, renderSpreadsheet } from "./charts";
  9. let CLIPBOARD = "";
  10. let PREFER_APP_CLIPBOARD = false;
  11. export const probablySupportsClipboardReadText =
  12. "clipboard" in navigator && "readText" in navigator.clipboard;
  13. export const probablySupportsClipboardWriteText =
  14. "clipboard" in navigator && "writeText" in navigator.clipboard;
  15. export const probablySupportsClipboardBlob =
  16. "clipboard" in navigator &&
  17. "write" in navigator.clipboard &&
  18. "ClipboardItem" in window &&
  19. "toBlob" in HTMLCanvasElement.prototype;
  20. export const copyToAppClipboard = async (
  21. elements: readonly NonDeletedExcalidrawElement[],
  22. appState: AppState,
  23. ) => {
  24. CLIPBOARD = JSON.stringify(getSelectedElements(elements, appState));
  25. try {
  26. // when copying to in-app clipboard, clear system clipboard so that if
  27. // system clip contains text on paste we know it was copied *after* user
  28. // copied elements, and thus we should prefer the text content.
  29. await copyTextToSystemClipboard(null);
  30. PREFER_APP_CLIPBOARD = false;
  31. } catch {
  32. // if clearing system clipboard didn't work, we should prefer in-app
  33. // clipboard even if there's text in system clipboard on paste, because
  34. // we can't be sure of the order of copy operations
  35. PREFER_APP_CLIPBOARD = true;
  36. }
  37. };
  38. export const getAppClipboard = (): {
  39. elements?: readonly ExcalidrawElement[];
  40. } => {
  41. if (!CLIPBOARD) {
  42. return {};
  43. }
  44. try {
  45. const clipboardElements = JSON.parse(CLIPBOARD);
  46. if (
  47. Array.isArray(clipboardElements) &&
  48. clipboardElements.length > 0 &&
  49. clipboardElements[0].type // need to implement a better check here...
  50. ) {
  51. return { elements: clipboardElements };
  52. }
  53. } catch (error) {
  54. console.error(error);
  55. }
  56. return {};
  57. };
  58. export const getClipboardContent = async (
  59. appState: AppState,
  60. cursorX: number,
  61. cursorY: number,
  62. event: ClipboardEvent | null,
  63. ): Promise<{
  64. text?: string;
  65. elements?: readonly ExcalidrawElement[];
  66. error?: string;
  67. }> => {
  68. try {
  69. const text = event
  70. ? event.clipboardData?.getData("text/plain").trim()
  71. : probablySupportsClipboardReadText &&
  72. (await navigator.clipboard.readText());
  73. if (text && !PREFER_APP_CLIPBOARD && !text.includes(SVG_EXPORT_TAG)) {
  74. const result = tryParseSpreadsheet(text);
  75. if (result.type === "spreadsheet") {
  76. return {
  77. elements: renderSpreadsheet(
  78. appState,
  79. result.spreadsheet,
  80. cursorX,
  81. cursorY,
  82. ),
  83. };
  84. } else if (result.type === "malformed spreadsheet") {
  85. return { error: result.error };
  86. }
  87. return { text };
  88. }
  89. } catch (error) {
  90. console.error(error);
  91. }
  92. return getAppClipboard();
  93. };
  94. export const copyCanvasToClipboardAsPng = async (canvas: HTMLCanvasElement) =>
  95. new Promise((resolve, reject) => {
  96. try {
  97. canvas.toBlob(async (blob: any) => {
  98. try {
  99. await navigator.clipboard.write([
  100. new window.ClipboardItem({ "image/png": blob }),
  101. ]);
  102. resolve();
  103. } catch (error) {
  104. reject(error);
  105. }
  106. });
  107. } catch (error) {
  108. reject(error);
  109. }
  110. });
  111. export const copyCanvasToClipboardAsSvg = async (svgroot: SVGSVGElement) => {
  112. try {
  113. await navigator.clipboard.writeText(svgroot.outerHTML);
  114. } catch (error) {
  115. console.error(error);
  116. }
  117. };
  118. export const copyTextToSystemClipboard = async (text: string | null) => {
  119. let copied = false;
  120. if (probablySupportsClipboardWriteText) {
  121. try {
  122. // NOTE: doesn't work on FF on non-HTTPS domains, or when document
  123. // not focused
  124. await navigator.clipboard.writeText(text || "");
  125. copied = true;
  126. } catch (error) {
  127. console.error(error);
  128. }
  129. }
  130. // Note that execCommand doesn't allow copying empty strings, so if we're
  131. // clearing clipboard using this API, we must copy at least an empty char
  132. if (!copied && !copyTextViaExecCommand(text || " ")) {
  133. throw new Error("couldn't copy");
  134. }
  135. };
  136. // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
  137. const copyTextViaExecCommand = (text: string) => {
  138. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  139. const textarea = document.createElement("textarea");
  140. textarea.style.border = "0";
  141. textarea.style.padding = "0";
  142. textarea.style.margin = "0";
  143. textarea.style.position = "absolute";
  144. textarea.style[isRTL ? "right" : "left"] = "-9999px";
  145. const yPosition = window.pageYOffset || document.documentElement.scrollTop;
  146. textarea.style.top = `${yPosition}px`;
  147. // Prevent zooming on iOS
  148. textarea.style.fontSize = "12pt";
  149. textarea.setAttribute("readonly", "");
  150. textarea.value = text;
  151. document.body.appendChild(textarea);
  152. let success = false;
  153. try {
  154. textarea.select();
  155. textarea.setSelectionRange(0, textarea.value.length);
  156. success = document.execCommand("copy");
  157. } catch (error) {
  158. console.error(error);
  159. }
  160. textarea.remove();
  161. return success;
  162. };