clipboard.ts 14 KB

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