Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 225 lines 7.8 kB view raw
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}