Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
at lambda-fork/main 380 lines 13 kB view raw
1/* 2 * Copyright (C) 2023-2025 Yomitan Authors 3 * Copyright (C) 2019-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 {EventDispatcher} from '../core/event-dispatcher.js'; 20import {log} from '../core/log.js'; 21 22/** 23 * This class is a proxy for a Popup that is hosted in a different frame. 24 * It effectively forwards all API calls to the underlying Popup. 25 * @augments EventDispatcher<import('popup').Events> 26 */ 27export class PopupProxy extends EventDispatcher { 28 /** 29 * @param {import('../application.js').Application} application The main application instance. 30 * @param {string} id The identifier of the popup. 31 * @param {number} depth The depth of the popup. 32 * @param {number} frameId The frameId of the host frame. 33 * @param {?import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} frameOffsetForwarder A `FrameOffsetForwarder` instance which is used to determine frame positioning. 34 */ 35 constructor(application, id, depth, frameId, frameOffsetForwarder) { 36 super(); 37 /** @type {import('../application.js').Application} */ 38 this._application = application; 39 /** @type {string} */ 40 this._id = id; 41 /** @type {number} */ 42 this._depth = depth; 43 /** @type {number} */ 44 this._frameId = frameId; 45 /** @type {?import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */ 46 this._frameOffsetForwarder = frameOffsetForwarder; 47 48 /** @type {number} */ 49 this._frameOffsetX = 0; 50 /** @type {number} */ 51 this._frameOffsetY = 0; 52 /** @type {?Promise<?[x: number, y: number]>} */ 53 this._frameOffsetPromise = null; 54 /** @type {?number} */ 55 this._frameOffsetUpdatedAt = null; 56 /** @type {number} */ 57 this._frameOffsetExpireTimeout = 1000; 58 } 59 60 /** 61 * The ID of the popup. 62 * @type {string} 63 */ 64 get id() { 65 return this._id; 66 } 67 68 /** 69 * The parent of the popup, which is always `null` for `PopupProxy` instances, 70 * since any potential parent popups are in a different frame. 71 * @type {?import('./popup.js').Popup} 72 */ 73 get parent() { 74 return null; 75 } 76 77 /** 78 * Attempts to set the parent popup. 79 * @param {import('./popup.js').Popup} _value The parent to assign. 80 * @throws {Error} Throws an error, since this class doesn't support a direct parent. 81 */ 82 set parent(_value) { 83 throw new Error('Not supported on PopupProxy'); 84 } 85 86 /** 87 * The popup child popup, which is always null for `PopupProxy` instances, 88 * since any potential child popups are in a different frame. 89 * @type {?import('./popup.js').Popup} 90 */ 91 get child() { 92 return null; 93 } 94 95 /** 96 * Attempts to set the child popup. 97 * @param {import('./popup.js').Popup} _child The child to assign. 98 * @throws {Error} Throws an error, since this class doesn't support children. 99 */ 100 set child(_child) { 101 throw new Error('Not supported on PopupProxy'); 102 } 103 104 /** 105 * The depth of the popup. 106 * @type {number} 107 */ 108 get depth() { 109 return this._depth; 110 } 111 112 /** 113 * Gets the content window of the frame. This value is null, 114 * since the window is hosted in a different frame. 115 * @type {?Window} 116 */ 117 get frameContentWindow() { 118 return null; 119 } 120 121 /** 122 * Gets the DOM node that contains the frame. 123 * @type {?Element} 124 */ 125 get container() { 126 return null; 127 } 128 129 /** 130 * Gets the ID of the frame. 131 * @type {number} 132 */ 133 get frameId() { 134 return this._frameId; 135 } 136 137 /** 138 * Sets the options context for the popup. 139 * @param {import('settings').OptionsContext} optionsContext The options context object. 140 * @returns {Promise<void>} 141 */ 142 async setOptionsContext(optionsContext) { 143 await this._invokeSafe('popupFactorySetOptionsContext', {id: this._id, optionsContext}, void 0); 144 } 145 146 /** 147 * Hides the popup. 148 * @param {boolean} changeFocus Whether or not the parent popup or host frame should be focused. 149 * @returns {Promise<void>} 150 */ 151 async hide(changeFocus) { 152 await this._invokeSafe('popupFactoryHide', {id: this._id, changeFocus}, void 0); 153 } 154 155 /** 156 * Returns whether or not the popup is currently visible. 157 * @returns {Promise<boolean>} `true` if the popup is visible, `false` otherwise. 158 */ 159 isVisible() { 160 return this._invokeSafe('popupFactoryIsVisible', {id: this._id}, false); 161 } 162 163 /** 164 * Force assigns the visibility of the popup. 165 * @param {boolean} value Whether or not the popup should be visible. 166 * @param {number} priority The priority of the override. 167 * @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`, 168 * or null if the override wasn't assigned. 169 */ 170 setVisibleOverride(value, priority) { 171 return this._invokeSafe('popupFactorySetVisibleOverride', {id: this._id, value, priority}, null); 172 } 173 174 /** 175 * Clears a visibility override that was generated by `setVisibleOverride`. 176 * @param {import('core').TokenString} token The token returned from `setVisibleOverride`. 177 * @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise. 178 */ 179 clearVisibleOverride(token) { 180 return this._invokeSafe('popupFactoryClearVisibleOverride', {id: this._id, token}, false); 181 } 182 183 /** 184 * Checks whether a point is contained within the popup's rect. 185 * @param {number} x The x coordinate. 186 * @param {number} y The y coordinate. 187 * @returns {Promise<boolean>} `true` if the point is contained within the popup's rect, `false` otherwise. 188 */ 189 async containsPoint(x, y) { 190 if (this._frameOffsetForwarder !== null) { 191 await this._updateFrameOffset(); 192 x += this._frameOffsetX; 193 y += this._frameOffsetY; 194 } 195 return await this._invokeSafe('popupFactoryContainsPoint', {id: this._id, x, y}, false); 196 } 197 198 /** 199 * Shows and updates the positioning and content of the popup. 200 * @param {import('popup').ContentDetails} details Settings for the outer popup. 201 * @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`. 202 * @returns {Promise<void>} 203 */ 204 async showContent(details, displayDetails) { 205 if (this._frameOffsetForwarder !== null) { 206 const {sourceRects} = details; 207 await this._updateFrameOffset(); 208 for (const sourceRect of sourceRects) { 209 sourceRect.left += this._frameOffsetX; 210 sourceRect.top += this._frameOffsetY; 211 sourceRect.right += this._frameOffsetX; 212 sourceRect.bottom += this._frameOffsetY; 213 } 214 } 215 await this._invokeSafe('popupFactoryShowContent', {id: this._id, details, displayDetails}, void 0); 216 } 217 218 /** 219 * Sets the custom styles for the popup content. 220 * @param {string} css The CSS rules. 221 * @returns {Promise<void>} 222 */ 223 async setCustomCss(css) { 224 await this._invokeSafe('popupFactorySetCustomCss', {id: this._id, css}, void 0); 225 } 226 227 /** 228 * Stops the audio auto-play timer, if one has started. 229 * @returns {Promise<void>} 230 */ 231 async clearAutoPlayTimer() { 232 await this._invokeSafe('popupFactoryClearAutoPlayTimer', {id: this._id}, void 0); 233 } 234 235 /** 236 * Sets the scaling factor of the popup content. 237 * @param {number} scale The scaling factor. 238 * @returns {Promise<void>} 239 */ 240 async setContentScale(scale) { 241 await this._invokeSafe('popupFactorySetContentScale', {id: this._id, scale}, void 0); 242 } 243 244 /** 245 * Returns whether or not the popup is currently visible, synchronously. 246 * @throws An exception is thrown for `PopupProxy` since it cannot synchronously detect visibility. 247 */ 248 isVisibleSync() { 249 throw new Error('Not supported on PopupProxy'); 250 } 251 252 /** 253 * Updates the outer theme of the popup. 254 * @returns {Promise<void>} 255 */ 256 async updateTheme() { 257 await this._invokeSafe('popupFactoryUpdateTheme', {id: this._id}, void 0); 258 } 259 260 /** 261 * Sets the custom styles for the outer popup container. 262 * @param {string} css The CSS rules. 263 * @param {boolean} useWebExtensionApi Whether or not web extension APIs should be used to inject the rules. 264 * When web extension APIs are used, a DOM node is not generated, making it harder to detect the changes. 265 * @returns {Promise<void>} 266 */ 267 async setCustomOuterCss(css, useWebExtensionApi) { 268 await this._invokeSafe('popupFactorySetCustomOuterCss', {id: this._id, css, useWebExtensionApi}, void 0); 269 } 270 271 /** 272 * Gets the rectangle of the DOM frame, synchronously. 273 * @returns {import('popup').ValidRect} The rect. 274 * `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame. 275 */ 276 getFrameRect() { 277 return {left: 0, top: 0, right: 0, bottom: 0, valid: false}; 278 } 279 280 /** 281 * Gets the size of the DOM frame. 282 * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid. 283 */ 284 getFrameSize() { 285 return this._invokeSafe('popupFactoryGetFrameSize', {id: this._id}, {width: 0, height: 0, valid: false}); 286 } 287 288 /** 289 * Sets the size of the DOM frame. 290 * @param {number} width The desired width of the popup. 291 * @param {number} height The desired height of the popup. 292 * @returns {Promise<boolean>} `true` if the size assignment was successful, `false` otherwise. 293 */ 294 setFrameSize(width, height) { 295 return this._invokeSafe('popupFactorySetFrameSize', {id: this._id, width, height}, false); 296 } 297 298 /** 299 * Checks if the pointer is over this popup. 300 * @returns {Promise<boolean>} Whether the pointer is over the popup 301 */ 302 isPointerOver() { 303 return this._invokeSafe('popupFactoryIsPointerOver', {id: this._id}, false); 304 } 305 306 // Private 307 308 /** 309 * @template {import('cross-frame-api').ApiNames} TName 310 * @param {TName} action 311 * @param {import('cross-frame-api').ApiParams<TName>} params 312 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>>} 313 */ 314 _invoke(action, params) { 315 return this._application.crossFrame.invoke(this._frameId, action, params); 316 } 317 318 /** 319 * @template {import('cross-frame-api').ApiNames} TName 320 * @template [TReturnDefault=unknown] 321 * @param {TName} action 322 * @param {import('cross-frame-api').ApiParams<TName>} params 323 * @param {TReturnDefault} defaultReturnValue 324 * @returns {Promise<import('cross-frame-api').ApiReturn<TName>|TReturnDefault>} 325 */ 326 async _invokeSafe(action, params, defaultReturnValue) { 327 try { 328 return await this._invoke(action, params); 329 } catch (e) { 330 if (!this._application.webExtension.unloaded) { throw e; } 331 return defaultReturnValue; 332 } 333 } 334 335 /** 336 * @returns {Promise<void>} 337 */ 338 async _updateFrameOffset() { 339 const now = Date.now(); 340 const firstRun = this._frameOffsetUpdatedAt === null; 341 const expired = firstRun || /** @type {number} */ (this._frameOffsetUpdatedAt) < now - this._frameOffsetExpireTimeout; 342 if (this._frameOffsetPromise === null && !expired) { return; } 343 344 if (this._frameOffsetPromise !== null) { 345 if (firstRun) { 346 await this._frameOffsetPromise; 347 } 348 return; 349 } 350 351 const promise = this._updateFrameOffsetInner(now); 352 if (firstRun) { 353 await promise; 354 } 355 } 356 357 /** 358 * @param {number} now 359 */ 360 async _updateFrameOffsetInner(now) { 361 this._frameOffsetPromise = /** @type {import('../comm/frame-offset-forwarder.js').FrameOffsetForwarder} */ (this._frameOffsetForwarder).getOffset(); 362 try { 363 const offset = await this._frameOffsetPromise; 364 if (offset !== null) { 365 this._frameOffsetX = offset[0]; 366 this._frameOffsetY = offset[1]; 367 } else { 368 this._frameOffsetX = 0; 369 this._frameOffsetY = 0; 370 this.trigger('offsetNotFound', {}); 371 return; 372 } 373 this._frameOffsetUpdatedAt = now; 374 } catch (e) { 375 log.error(e); 376 } finally { 377 this._frameOffsetPromise = null; 378 } 379 } 380}