clipboard.ts 6.4 KB

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