clipboard.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import type {
  2. ExcalidrawElement,
  3. NonDeletedExcalidrawElement,
  4. } from "./element/types";
  5. import type { BinaryFiles } from "./types";
  6. import type { Spreadsheet } from "./charts";
  7. import { tryParseSpreadsheet, VALID_SPREADSHEET } from "./charts";
  8. import {
  9. ALLOWED_PASTE_MIME_TYPES,
  10. EXPORT_DATA_TYPES,
  11. MIME_TYPES,
  12. } from "./constants";
  13. import {
  14. isFrameLikeElement,
  15. isInitializedImageElement,
  16. } from "./element/typeChecks";
  17. import { deepCopyElement } from "./element/newElement";
  18. import { mutateElement } from "./element/mutateElement";
  19. import { getContainingFrame } from "./frame";
  20. import { arrayToMap, isMemberOf, isPromiseLike } from "./utils";
  21. type ElementsClipboard = {
  22. type: typeof EXPORT_DATA_TYPES.excalidrawClipboard;
  23. elements: readonly NonDeletedExcalidrawElement[];
  24. files: BinaryFiles | undefined;
  25. };
  26. export type PastedMixedContent = { type: "text" | "imageUrl"; value: string }[];
  27. export interface ClipboardData {
  28. spreadsheet?: Spreadsheet;
  29. elements?: readonly ExcalidrawElement[];
  30. files?: BinaryFiles;
  31. text?: string;
  32. mixedContent?: PastedMixedContent;
  33. errorMessage?: string;
  34. programmaticAPI?: boolean;
  35. }
  36. type AllowedPasteMimeTypes = typeof ALLOWED_PASTE_MIME_TYPES[number];
  37. type ParsedClipboardEvent =
  38. | { type: "text"; value: string }
  39. | { type: "mixedContent"; value: PastedMixedContent };
  40. export const probablySupportsClipboardReadText =
  41. "clipboard" in navigator && "readText" in navigator.clipboard;
  42. export const probablySupportsClipboardWriteText =
  43. "clipboard" in navigator && "writeText" in navigator.clipboard;
  44. export const probablySupportsClipboardBlob =
  45. "clipboard" in navigator &&
  46. "write" in navigator.clipboard &&
  47. "ClipboardItem" in window &&
  48. "toBlob" in HTMLCanvasElement.prototype;
  49. const clipboardContainsElements = (
  50. contents: any,
  51. ): contents is { elements: ExcalidrawElement[]; files?: BinaryFiles } => {
  52. if (
  53. [
  54. EXPORT_DATA_TYPES.excalidraw,
  55. EXPORT_DATA_TYPES.excalidrawClipboard,
  56. EXPORT_DATA_TYPES.excalidrawClipboardWithAPI,
  57. ].includes(contents?.type) &&
  58. Array.isArray(contents.elements)
  59. ) {
  60. return true;
  61. }
  62. return false;
  63. };
  64. export const createPasteEvent = ({
  65. types,
  66. files,
  67. }: {
  68. types?: { [key in AllowedPasteMimeTypes]?: string };
  69. files?: File[];
  70. }) => {
  71. if (!types && !files) {
  72. console.warn("createPasteEvent: no types or files provided");
  73. }
  74. const event = new ClipboardEvent("paste", {
  75. clipboardData: new DataTransfer(),
  76. });
  77. if (types) {
  78. for (const [type, value] of Object.entries(types)) {
  79. try {
  80. event.clipboardData?.setData(type, value);
  81. if (event.clipboardData?.getData(type) !== value) {
  82. throw new Error(`Failed to set "${type}" as clipboardData item`);
  83. }
  84. } catch (error: any) {
  85. throw new Error(error.message);
  86. }
  87. }
  88. }
  89. if (files) {
  90. let idx = -1;
  91. for (const file of files) {
  92. idx++;
  93. try {
  94. event.clipboardData?.items.add(file);
  95. if (event.clipboardData?.files[idx] !== file) {
  96. throw new Error(
  97. `Failed to set file "${file.name}" as clipboardData item`,
  98. );
  99. }
  100. } catch (error: any) {
  101. throw new Error(error.message);
  102. }
  103. }
  104. }
  105. return event;
  106. };
  107. export const serializeAsClipboardJSON = ({
  108. elements,
  109. files,
  110. }: {
  111. elements: readonly NonDeletedExcalidrawElement[];
  112. files: BinaryFiles | null;
  113. }) => {
  114. const elementsMap = arrayToMap(elements);
  115. const framesToCopy = new Set(
  116. elements.filter((element) => isFrameLikeElement(element)),
  117. );
  118. let foundFile = false;
  119. const _files = elements.reduce((acc, element) => {
  120. if (isInitializedImageElement(element)) {
  121. foundFile = true;
  122. if (files && files[element.fileId]) {
  123. acc[element.fileId] = files[element.fileId];
  124. }
  125. }
  126. return acc;
  127. }, {} as BinaryFiles);
  128. if (foundFile && !files) {
  129. console.warn(
  130. "copyToClipboard: attempting to file element(s) without providing associated `files` object.",
  131. );
  132. }
  133. // select bound text elements when copying
  134. const contents: ElementsClipboard = {
  135. type: EXPORT_DATA_TYPES.excalidrawClipboard,
  136. elements: elements.map((element) => {
  137. if (
  138. getContainingFrame(element, elementsMap) &&
  139. !framesToCopy.has(getContainingFrame(element, elementsMap)!)
  140. ) {
  141. const copiedElement = deepCopyElement(element);
  142. mutateElement(copiedElement, {
  143. frameId: null,
  144. });
  145. return copiedElement;
  146. }
  147. return element;
  148. }),
  149. files: files ? _files : undefined,
  150. };
  151. return JSON.stringify(contents);
  152. };
  153. export const copyToClipboard = async (
  154. elements: readonly NonDeletedExcalidrawElement[],
  155. files: BinaryFiles | null,
  156. /** supply if available to make the operation more certain to succeed */
  157. clipboardEvent?: ClipboardEvent | null,
  158. ) => {
  159. await copyTextToSystemClipboard(
  160. serializeAsClipboardJSON({ elements, files }),
  161. clipboardEvent,
  162. );
  163. };
  164. const parsePotentialSpreadsheet = (
  165. text: string,
  166. ): { spreadsheet: Spreadsheet } | { errorMessage: string } | null => {
  167. const result = tryParseSpreadsheet(text);
  168. if (result.type === VALID_SPREADSHEET) {
  169. return { spreadsheet: result.spreadsheet };
  170. }
  171. return null;
  172. };
  173. /** internal, specific to parsing paste events. Do not reuse. */
  174. function parseHTMLTree(el: ChildNode) {
  175. let result: PastedMixedContent = [];
  176. for (const node of el.childNodes) {
  177. if (node.nodeType === 3) {
  178. const text = node.textContent?.trim();
  179. if (text) {
  180. result.push({ type: "text", value: text });
  181. }
  182. } else if (node instanceof HTMLImageElement) {
  183. const url = node.getAttribute("src");
  184. if (url && url.startsWith("http")) {
  185. result.push({ type: "imageUrl", value: url });
  186. }
  187. } else {
  188. result = result.concat(parseHTMLTree(node));
  189. }
  190. }
  191. return result;
  192. }
  193. const maybeParseHTMLPaste = (
  194. event: ClipboardEvent,
  195. ): { type: "mixedContent"; value: PastedMixedContent } | null => {
  196. const html = event.clipboardData?.getData("text/html");
  197. if (!html) {
  198. return null;
  199. }
  200. try {
  201. const doc = new DOMParser().parseFromString(html, "text/html");
  202. const content = parseHTMLTree(doc.body);
  203. if (content.length) {
  204. return { type: "mixedContent", value: content };
  205. }
  206. } catch (error: any) {
  207. console.error(`error in parseHTMLFromPaste: ${error.message}`);
  208. }
  209. return null;
  210. };
  211. export const readSystemClipboard = async () => {
  212. const types: { [key in AllowedPasteMimeTypes]?: string } = {};
  213. try {
  214. if (navigator.clipboard?.readText) {
  215. return { "text/plain": await navigator.clipboard?.readText() };
  216. }
  217. } catch (error: any) {
  218. // @ts-ignore
  219. if (navigator.clipboard?.read) {
  220. console.warn(
  221. `navigator.clipboard.readText() failed (${error.message}). Failling back to navigator.clipboard.read()`,
  222. );
  223. } else {
  224. throw error;
  225. }
  226. }
  227. let clipboardItems: ClipboardItems;
  228. try {
  229. clipboardItems = await navigator.clipboard?.read();
  230. } catch (error: any) {
  231. if (error.name === "DataError") {
  232. console.warn(
  233. `navigator.clipboard.read() error, clipboard is probably empty: ${error.message}`,
  234. );
  235. return types;
  236. }
  237. throw error;
  238. }
  239. for (const item of clipboardItems) {
  240. for (const type of item.types) {
  241. if (!isMemberOf(ALLOWED_PASTE_MIME_TYPES, type)) {
  242. continue;
  243. }
  244. try {
  245. types[type] = await (await item.getType(type)).text();
  246. } catch (error: any) {
  247. console.warn(
  248. `Cannot retrieve ${type} from clipboardItem: ${error.message}`,
  249. );
  250. }
  251. }
  252. }
  253. if (Object.keys(types).length === 0) {
  254. console.warn("No clipboard data found from clipboard.read().");
  255. return types;
  256. }
  257. return types;
  258. };
  259. /**
  260. * Parses "paste" ClipboardEvent.
  261. */
  262. const parseClipboardEvent = async (
  263. event: ClipboardEvent,
  264. isPlainPaste = false,
  265. ): Promise<ParsedClipboardEvent> => {
  266. try {
  267. const mixedContent = !isPlainPaste && event && maybeParseHTMLPaste(event);
  268. if (mixedContent) {
  269. if (mixedContent.value.every((item) => item.type === "text")) {
  270. return {
  271. type: "text",
  272. value:
  273. event.clipboardData?.getData("text/plain") ||
  274. mixedContent.value
  275. .map((item) => item.value)
  276. .join("\n")
  277. .trim(),
  278. };
  279. }
  280. return mixedContent;
  281. }
  282. const text = event.clipboardData?.getData("text/plain");
  283. return { type: "text", value: (text || "").trim() };
  284. } catch {
  285. return { type: "text", value: "" };
  286. }
  287. };
  288. /**
  289. * Attempts to parse clipboard. Prefers system clipboard.
  290. */
  291. export const parseClipboard = async (
  292. event: ClipboardEvent,
  293. isPlainPaste = false,
  294. ): Promise<ClipboardData> => {
  295. const parsedEventData = await parseClipboardEvent(event, isPlainPaste);
  296. if (parsedEventData.type === "mixedContent") {
  297. return {
  298. mixedContent: parsedEventData.value,
  299. };
  300. }
  301. try {
  302. // if system clipboard contains spreadsheet, use it even though it's
  303. // technically possible it's staler than in-app clipboard
  304. const spreadsheetResult =
  305. !isPlainPaste && parsePotentialSpreadsheet(parsedEventData.value);
  306. if (spreadsheetResult) {
  307. return spreadsheetResult;
  308. }
  309. } catch (error: any) {
  310. console.error(error);
  311. }
  312. try {
  313. const systemClipboardData = JSON.parse(parsedEventData.value);
  314. const programmaticAPI =
  315. systemClipboardData.type === EXPORT_DATA_TYPES.excalidrawClipboardWithAPI;
  316. if (clipboardContainsElements(systemClipboardData)) {
  317. return {
  318. elements: systemClipboardData.elements,
  319. files: systemClipboardData.files,
  320. text: isPlainPaste
  321. ? JSON.stringify(systemClipboardData.elements, null, 2)
  322. : undefined,
  323. programmaticAPI,
  324. };
  325. }
  326. } catch {}
  327. return { text: parsedEventData.value };
  328. };
  329. export const copyBlobToClipboardAsPng = async (blob: Blob | Promise<Blob>) => {
  330. try {
  331. // in Safari so far we need to construct the ClipboardItem synchronously
  332. // (i.e. in the same tick) otherwise browser will complain for lack of
  333. // user intent. Using a Promise ClipboardItem constructor solves this.
  334. // https://bugs.webkit.org/show_bug.cgi?id=222262
  335. //
  336. // Note that Firefox (and potentially others) seems to support Promise
  337. // ClipboardItem constructor, but throws on an unrelated MIME type error.
  338. // So we need to await this and fallback to awaiting the blob if applicable.
  339. await navigator.clipboard.write([
  340. new window.ClipboardItem({
  341. [MIME_TYPES.png]: blob,
  342. }),
  343. ]);
  344. } catch (error: any) {
  345. // if we're using a Promise ClipboardItem, let's try constructing
  346. // with resolution value instead
  347. if (isPromiseLike(blob)) {
  348. await navigator.clipboard.write([
  349. new window.ClipboardItem({
  350. [MIME_TYPES.png]: await blob,
  351. }),
  352. ]);
  353. } else {
  354. throw error;
  355. }
  356. }
  357. };
  358. export const copyTextToSystemClipboard = async (
  359. text: string | null,
  360. clipboardEvent?: ClipboardEvent | null,
  361. ) => {
  362. // (1) first try using Async Clipboard API
  363. if (probablySupportsClipboardWriteText) {
  364. try {
  365. // NOTE: doesn't work on FF on non-HTTPS domains, or when document
  366. // not focused
  367. await navigator.clipboard.writeText(text || "");
  368. return;
  369. } catch (error: any) {
  370. console.error(error);
  371. }
  372. }
  373. // (2) if fails and we have access to ClipboardEvent, use plain old setData()
  374. try {
  375. if (clipboardEvent) {
  376. clipboardEvent.clipboardData?.setData("text/plain", text || "");
  377. if (clipboardEvent.clipboardData?.getData("text/plain") !== text) {
  378. throw new Error("Failed to setData on clipboardEvent");
  379. }
  380. return;
  381. }
  382. } catch (error: any) {
  383. console.error(error);
  384. }
  385. // (3) if that fails, use document.execCommand
  386. if (!copyTextViaExecCommand(text)) {
  387. throw new Error("Error copying to clipboard.");
  388. }
  389. };
  390. // adapted from https://github.com/zenorocha/clipboard.js/blob/ce79f170aa655c408b6aab33c9472e8e4fa52e19/src/clipboard-action.js#L48
  391. const copyTextViaExecCommand = (text: string | null) => {
  392. // execCommand doesn't allow copying empty strings, so if we're
  393. // clearing clipboard using this API, we must copy at least an empty char
  394. if (!text) {
  395. text = " ";
  396. }
  397. const isRTL = document.documentElement.getAttribute("dir") === "rtl";
  398. const textarea = document.createElement("textarea");
  399. textarea.style.border = "0";
  400. textarea.style.padding = "0";
  401. textarea.style.margin = "0";
  402. textarea.style.position = "absolute";
  403. textarea.style[isRTL ? "right" : "left"] = "-9999px";
  404. const yPosition = window.pageYOffset || document.documentElement.scrollTop;
  405. textarea.style.top = `${yPosition}px`;
  406. // Prevent zooming on iOS
  407. textarea.style.fontSize = "12pt";
  408. textarea.setAttribute("readonly", "");
  409. textarea.value = text;
  410. document.body.appendChild(textarea);
  411. let success = false;
  412. try {
  413. textarea.select();
  414. textarea.setSelectionRange(0, textarea.value.length);
  415. success = document.execCommand("copy");
  416. } catch (error: any) {
  417. console.error(error);
  418. }
  419. textarea.remove();
  420. return success;
  421. };