Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
1/*
2 * Copyright (C) 2023-2025 Yomitan Authors
3 * Copyright (C) 2020-2022 Yomichan Authors
4 *
5 * This program is free software: you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation, either version 3 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License
16 * along with this program. If not, see <https://www.gnu.org/licenses/>.
17 */
18
19import {getFileExtensionFromImageMediaType} from '../media/media-util.js';
20
21/**
22 * Class which can read text and images from the clipboard.
23 */
24export class ClipboardReader {
25 /**
26 * @param {?Document} document
27 * @param {?string} pasteTargetSelector
28 * @param {?string} richContentPasteTargetSelector
29 */
30 constructor(document, pasteTargetSelector, richContentPasteTargetSelector) {
31 /** @type {?Document} */
32 this._document = document;
33 /** @type {?import('environment').Browser} */
34 this._browser = null;
35 /** @type {?HTMLTextAreaElement} */
36 this._pasteTarget = null;
37 /** @type {?string} */
38 this._pasteTargetSelector = pasteTargetSelector;
39 /** @type {?HTMLElement} */
40 this._richContentPasteTarget = null;
41 /** @type {?string} */
42 this._richContentPasteTargetSelector = richContentPasteTargetSelector;
43 }
44
45 /**
46 * Gets the browser being used.
47 * @type {?import('environment').Browser}
48 */
49 get browser() {
50 return this._browser;
51 }
52
53 /**
54 * Assigns the browser being used.
55 */
56 set browser(value) {
57 this._browser = value;
58 }
59
60 /**
61 * Gets the text in the clipboard.
62 * @param {boolean} useRichText Whether or not to use rich text for pasting, when possible.
63 * @returns {Promise<string>} A string containing the clipboard text.
64 * @throws {Error} Error if not supported.
65 */
66 async getText(useRichText) {
67 /*
68 Notes:
69 document.execCommand('paste') sometimes doesn't work on Firefox.
70 See: https://bugzilla.mozilla.org/show_bug.cgi?id=1603985
71 Therefore, navigator.clipboard.readText() is used on Firefox.
72
73 navigator.clipboard.readText() can't be used in Chrome for two reasons:
74 * Requires page to be focused, else it rejects with an exception.
75 * When the page is focused, Chrome will request clipboard permission, despite already
76 being an extension with clipboard permissions. It effectively asks for the
77 non-extension permission for clipboard access.
78 */
79 if (this._isFirefox() && !useRichText) {
80 try {
81 return await navigator.clipboard.readText();
82 } catch (e) {
83 // Error is undefined, due to permissions
84 throw new Error('Cannot read clipboard text; check extension permissions');
85 }
86 }
87
88 const document = this._document;
89 if (document === null) {
90 throw new Error('Clipboard reading not supported in this context');
91 }
92
93 if (useRichText) {
94 const target = this._getRichContentPasteTarget();
95 target.focus();
96 document.execCommand('paste');
97 const result = /** @type {string} */ (target.textContent);
98 this._clearRichContent(target);
99 return result;
100 } else {
101 const target = this._getPasteTarget();
102 target.value = '';
103 target.focus();
104 document.execCommand('paste');
105 const result = target.value;
106 target.value = '';
107 return (typeof result === 'string' ? result : '');
108 }
109 }
110
111 /**
112 * Gets the first image in the clipboard.
113 * @returns {Promise<?string>} A string containing a data URL of the image file, or null if no image was found.
114 * @throws {Error} Error if not supported.
115 */
116 async getImage() {
117 // See browser-specific notes in getText
118 if (
119 this._isFirefox() &&
120 typeof navigator.clipboard !== 'undefined' &&
121 typeof navigator.clipboard.read === 'function'
122 ) {
123 // This function is behind the Firefox flag: dom.events.asyncClipboard.read
124 // See: https://developer.mozilla.org/en-US/docs/Web/API/Clipboard/read#browser_compatibility
125 let items;
126 try {
127 items = await navigator.clipboard.read();
128 } catch (e) {
129 return null;
130 }
131
132 for (const item of items) {
133 for (const type of item.types) {
134 if (!getFileExtensionFromImageMediaType(type)) { continue; }
135 try {
136 const blob = await item.getType(type);
137 return await this._readFileAsDataURL(blob);
138 } catch (e) {
139 // NOP
140 }
141 }
142 }
143 return null;
144 }
145
146 const document = this._document;
147 if (document === null) {
148 throw new Error('Clipboard reading not supported in this context');
149 }
150
151 const target = this._getRichContentPasteTarget();
152 target.focus();
153 document.execCommand('paste');
154 const image = target.querySelector('img[src^="data:"]');
155 const result = (image !== null ? image.getAttribute('src') : null);
156 this._clearRichContent(target);
157 return result;
158 }
159
160 // Private
161
162 /**
163 * @returns {boolean}
164 */
165 _isFirefox() {
166 return (this._browser === 'firefox' || this._browser === 'firefox-mobile');
167 }
168
169 /**
170 * @param {Blob} file
171 * @returns {Promise<string>}
172 */
173 _readFileAsDataURL(file) {
174 return new Promise((resolve, reject) => {
175 const reader = new FileReader();
176 reader.onload = () => resolve(/** @type {string} */ (reader.result));
177 reader.onerror = () => reject(reader.error);
178 reader.readAsDataURL(file);
179 });
180 }
181
182 /**
183 * @returns {HTMLTextAreaElement}
184 */
185 _getPasteTarget() {
186 if (this._pasteTarget === null) {
187 this._pasteTarget = /** @type {HTMLTextAreaElement} */ (this._findPasteTarget(this._pasteTargetSelector));
188 }
189 return this._pasteTarget;
190 }
191
192 /**
193 * @returns {HTMLElement}
194 */
195 _getRichContentPasteTarget() {
196 if (this._richContentPasteTarget === null) {
197 this._richContentPasteTarget = /** @type {HTMLElement} */ (this._findPasteTarget(this._richContentPasteTargetSelector));
198 }
199 return this._richContentPasteTarget;
200 }
201
202 /**
203 * @template {Element} T
204 * @param {?string} selector
205 * @returns {T}
206 * @throws {Error}
207 */
208 _findPasteTarget(selector) {
209 if (selector === null) { throw new Error('Invalid selector'); }
210 const target = this._document !== null ? this._document.querySelector(selector) : null;
211 if (target === null) { throw new Error('Clipboard paste target does not exist'); }
212 return /** @type {T} */ (target);
213 }
214
215 /**
216 * @param {HTMLElement} element
217 */
218 _clearRichContent(element) {
219 for (const image of element.querySelectorAll('img')) {
220 image.removeAttribute('src');
221 image.removeAttribute('srcset');
222 }
223 element.textContent = '';
224 }
225}