Pop-up dictionary browser extension for language learning. Successor to Yomichan. (PERSONAL FORK)
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}