1import {
2  ClipboardUnavailableException,
3  CopyFailureException,
4  NoPermissionException,
5  PasteFailureException,
6} from './Exceptions';
7import {
8  base64toBlob,
9  blobToBase64Async,
10  findHtmlInClipboardAsync,
11  findImageInClipboardAsync,
12  getImageSizeFromBlobAsync,
13  htmlToPlainText,
14  isClipboardPermissionDeniedAsync,
15} from './Utils';
16import {
17  ClipboardImage,
18  GetImageOptions,
19  GetStringOptions,
20  SetStringOptions,
21  StringFormat,
22} from '../Clipboard.types';
23
24export default {
25  get name(): string {
26    return 'ExpoClipboard';
27  },
28  async getStringAsync(options: GetStringOptions): Promise<string> {
29    if (!navigator.clipboard) {
30      throw new ClipboardUnavailableException();
31    }
32
33    try {
34      switch (options.preferredFormat) {
35        case StringFormat.HTML: {
36          // Try reading HTML first
37          const clipboardItems = await navigator.clipboard.read();
38          const blob = await findHtmlInClipboardAsync(clipboardItems);
39          if (!blob) {
40            // Fall back to plain text
41            return await navigator.clipboard.readText();
42          }
43          return await new Response(blob).text();
44        }
45        default: {
46          let text = await navigator.clipboard.readText();
47          if (!text || text === '') {
48            // If there's no direct plain text, try reading HTML
49            const clipboardItems = await navigator.clipboard.read();
50            const blob = await findHtmlInClipboardAsync(clipboardItems);
51            const blobText = await blob?.text();
52            text = htmlToPlainText(blobText ?? '');
53          }
54          return text;
55        }
56      }
57    } catch (e) {
58      // it might fail, because user denied permission
59      if (e.name === 'NotAllowedError' || (await isClipboardPermissionDeniedAsync())) {
60        throw new NoPermissionException();
61      }
62
63      try {
64        // Internet Explorer
65        // @ts-ignore
66        return window.clipboardData.getData('Text');
67      } catch {
68        return Promise.reject(new Error('Unable to retrieve item from clipboard'));
69      }
70    }
71  },
72  // TODO: (barthap) The `setString` was deprecated in SDK 45. Remove this function in a few SDK cycles.
73  setString(text: string): boolean {
74    const textField = document.createElement('textarea');
75    textField.textContent = text;
76    document.body.appendChild(textField);
77    textField.select();
78    try {
79      document.execCommand('copy');
80      return true;
81    } catch {
82      return false;
83    } finally {
84      document.body.removeChild(textField);
85    }
86  },
87  async setStringAsync(text: string, options: SetStringOptions): Promise<boolean> {
88    switch (options.inputFormat) {
89      case StringFormat.HTML: {
90        if (!navigator.clipboard) {
91          throw new ClipboardUnavailableException();
92        }
93
94        try {
95          const clipboardItemInput = createHtmlClipboardItem(text);
96          await navigator.clipboard.write([clipboardItemInput]);
97          return true;
98        } catch (e) {
99          // it might fail, because user denied permission
100          if (e.name === 'NotAllowedError' || (await isClipboardPermissionDeniedAsync())) {
101            throw new NoPermissionException();
102          }
103          throw new CopyFailureException(e.message);
104        }
105      }
106      default: {
107        try {
108          if (!navigator.clipboard) {
109            throw new Error();
110          }
111          await navigator.clipboard.writeText(text);
112          return true;
113        } catch {
114          // we can fall back to legacy behavior in any kind of failure
115          // including navigator.clipboard unavailability
116          return this.setString(text);
117        }
118      }
119    }
120  },
121  async hasStringAsync(): Promise<boolean> {
122    return await clipboardHasTypesAsync(['text/plain', 'text/html']);
123  },
124  async getImageAsync(_options: GetImageOptions): Promise<ClipboardImage | null> {
125    if (!navigator.clipboard) {
126      throw new ClipboardUnavailableException();
127    }
128
129    try {
130      const clipboardItems = await navigator.clipboard.read();
131      const blob = await findImageInClipboardAsync(clipboardItems);
132      if (!blob) {
133        return null;
134      }
135
136      const [data, size] = await Promise.all([
137        blobToBase64Async(blob),
138        getImageSizeFromBlobAsync(blob),
139      ]);
140
141      return { data, size };
142    } catch (e) {
143      // it might fail, because user denied permission
144      if (e.name === 'NotAllowedError' || (await isClipboardPermissionDeniedAsync())) {
145        throw new NoPermissionException();
146      }
147      throw new PasteFailureException(e.message);
148    }
149  },
150  async setImageAsync(base64image: string): Promise<void> {
151    if (!navigator.clipboard) {
152      throw new ClipboardUnavailableException();
153    }
154
155    try {
156      // we set it always to `image/png` because it's the only format supported by the clipboard
157      // but it seems to work even when provided jpeg data
158      const blob = base64toBlob(base64image, 'image/png');
159      await navigator.clipboard.write([
160        new ClipboardItem({
161          [blob.type]: blob,
162        }),
163      ]);
164    } catch (err: any) {
165      throw new CopyFailureException(err.message);
166    }
167  },
168  async hasImageAsync(): Promise<boolean> {
169    return await clipboardHasTypesAsync(['image/png', 'image/jpeg']);
170  },
171  addClipboardListener(): void {},
172  removeClipboardListener(): void {},
173};
174
175/**
176 * Resolves to true if clipboard has one of provided {@link types}.
177 * @throws `ClipboardUnavailableException` if AsyncClipboard API is not available
178 * @throws `NoPermissionException` if user denied permission
179 */
180async function clipboardHasTypesAsync(types: string[]): Promise<boolean> {
181  if (!navigator.clipboard) {
182    throw new ClipboardUnavailableException();
183  }
184
185  try {
186    const clipboardItems = await navigator.clipboard.read();
187    return clipboardItems.flatMap((item) => item.types).some((type) => types.includes(type));
188  } catch (e) {
189    // it might fail, because user denied permission
190    if (e.name === 'NotAllowedError' || (await isClipboardPermissionDeniedAsync())) {
191      throw new NoPermissionException();
192    }
193    throw e;
194  }
195}
196
197function createHtmlClipboardItem(htmlString: string): ClipboardItem {
198  return new ClipboardItem({
199    // @ts-ignore `Blob` from `lib.dom.d.ts` and the one from `@types/react-native` differ
200    'text/html': new Blob([htmlString], { type: 'text/html' }),
201    // @ts-ignore `Blob` from `lib.dom.d.ts` and the one from `@types/react-native` differ
202    'text/plain': new Blob([htmlToPlainText(htmlString)], { type: 'text/plain' }),
203  });
204}
205