clipboard.ts 4.7 KB

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