summaryrefslogtreecommitdiff
path: root/ext/js/app
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/app')
-rw-r--r--ext/js/app/content-script-main.js2
-rw-r--r--ext/js/app/frontend.js308
-rw-r--r--ext/js/app/popup-factory.js182
-rw-r--r--ext/js/app/popup-proxy.js102
-rw-r--r--ext/js/app/popup-window.js67
-rw-r--r--ext/js/app/popup.js333
-rw-r--r--ext/js/app/theme-controller.js28
7 files changed, 742 insertions, 280 deletions
diff --git a/ext/js/app/content-script-main.js b/ext/js/app/content-script-main.js
index a042f3bf..972d032c 100644
--- a/ext/js/app/content-script-main.js
+++ b/ext/js/app/content-script-main.js
@@ -46,7 +46,9 @@ import {PopupFactory} from './popup-factory.js';
parentFrameId: null,
useProxyPopup: false,
pageType: 'web',
+ canUseWindowPopup: true,
allowRootFramePopupProxy: true,
+ childrenSupported: true,
hotkeyHandler
});
await frontend.prepare();
diff --git a/ext/js/app/frontend.js b/ext/js/app/frontend.js
index 4c13eac2..fec933f8 100644
--- a/ext/js/app/frontend.js
+++ b/ext/js/app/frontend.js
@@ -17,7 +17,7 @@
*/
import {GoogleDocsUtil} from '../accessibility/google-docs-util.js';
-import {EventListenerCollection, invokeMessageHandler, isObject, log, promiseAnimationFrame} from '../core.js';
+import {EventListenerCollection, invokeMessageHandler, log, promiseAnimationFrame} from '../core.js';
import {DocumentUtil} from '../dom/document-util.js';
import {TextSourceElement} from '../dom/text-source-element.js';
import {TextSourceRange} from '../dom/text-source-range.js';
@@ -25,7 +25,6 @@ import {HotkeyHandler} from '../input/hotkey-handler.js';
import {TextScanner} from '../language/text-scanner.js';
import {yomitan} from '../yomitan.js';
import {PopupFactory} from './popup-factory.js';
-import {Popup} from './popup.js';
/**
* This is the main class responsible for scanning and handling webpage content.
@@ -33,19 +32,7 @@ import {Popup} from './popup.js';
export class Frontend {
/**
* Creates a new instance.
- * @param {object} details Details about how to set up the instance.
- * @param {string} details.pageType The type of page, one of 'web', 'popup', or 'search'.
- * @param {PopupFactory} details.popupFactory A PopupFactory instance to use for generating popups.
- * @param {number} details.depth The nesting depth value of the popup.
- * @param {number} details.tabId The tab ID of the host tab.
- * @param {number} details.frameId The frame ID of the host frame.
- * @param {?string} details.parentPopupId The popup ID of the parent popup if one exists, otherwise null.
- * @param {?number} details.parentFrameId The frame ID of the parent popup if one exists, otherwise null.
- * @param {boolean} details.useProxyPopup Whether or not proxy popups should be used.
- * @param {boolean} details.canUseWindowPopup Whether or not window popups can be used.
- * @param {boolean} details.allowRootFramePopupProxy Whether or not popups can be hosted in the root frame.
- * @param {boolean} details.childrenSupported Whether popups can create child popups or not.
- * @param {HotkeyHandler} details.hotkeyHandler A HotkeyHandler instance.
+ * @param {import('frontend').ConstructorDetails} details Details about how to set up the instance.
*/
constructor({
pageType,
@@ -61,24 +48,43 @@ export class Frontend {
childrenSupported=true,
hotkeyHandler
}) {
+ /** @type {import('frontend').PageType} */
this._pageType = pageType;
+ /** @type {PopupFactory} */
this._popupFactory = popupFactory;
+ /** @type {number} */
this._depth = depth;
+ /** @type {number|undefined} */
this._tabId = tabId;
+ /** @type {number} */
this._frameId = frameId;
+ /** @type {?string} */
this._parentPopupId = parentPopupId;
+ /** @type {?number} */
this._parentFrameId = parentFrameId;
+ /** @type {boolean} */
this._useProxyPopup = useProxyPopup;
+ /** @type {boolean} */
this._canUseWindowPopup = canUseWindowPopup;
+ /** @type {boolean} */
this._allowRootFramePopupProxy = allowRootFramePopupProxy;
+ /** @type {boolean} */
this._childrenSupported = childrenSupported;
+ /** @type {HotkeyHandler} */
this._hotkeyHandler = hotkeyHandler;
+ /** @type {?import('popup').PopupAny} */
this._popup = null;
+ /** @type {boolean} */
this._disabledOverride = false;
+ /** @type {?import('settings').ProfileOptions} */
this._options = null;
+ /** @type {number} */
this._pageZoomFactor = 1.0;
+ /** @type {number} */
this._contentScale = 1.0;
+ /** @type {Promise<void>} */
this._lastShowPromise = Promise.resolve();
+ /** @type {TextScanner} */
this._textScanner = new TextScanner({
node: window,
ignoreElements: this._ignoreElements.bind(this),
@@ -87,19 +93,27 @@ export class Frontend {
searchTerms: true,
searchKanji: true
});
+ /** @type {boolean} */
this._textScannerHasBeenEnabled = false;
+ /** @type {Map<'default'|'window'|'iframe'|'proxy', Promise<?import('popup').PopupAny>>} */
this._popupCache = new Map();
+ /** @type {EventListenerCollection} */
this._popupEventListeners = new EventListenerCollection();
+ /** @type {?import('core').TokenObject} */
this._updatePopupToken = null;
+ /** @type {?number} */
this._clearSelectionTimer = null;
+ /** @type {boolean} */
this._isPointerOverPopup = false;
+ /** @type {?import('settings').OptionsContext} */
this._optionsContextOverride = null;
- this._runtimeMessageHandlers = new Map([
+ /** @type {import('core').MessageHandlerMap} */
+ this._runtimeMessageHandlers = new Map(/** @type {import('core').MessageHandlerArray} */ ([
['Frontend.requestReadyBroadcast', {async: false, handler: this._onMessageRequestFrontendReadyBroadcast.bind(this)}],
['Frontend.setAllVisibleOverride', {async: true, handler: this._onApiSetAllVisibleOverride.bind(this)}],
['Frontend.clearAllVisibleOverride', {async: true, handler: this._onApiClearAllVisibleOverride.bind(this)}]
- ]);
+ ]));
this._hotkeyHandler.registerActions([
['scanSelectedText', this._onActionScanSelectedText.bind(this)],
@@ -125,7 +139,7 @@ export class Frontend {
/**
* Gets the popup instance.
- * @type {Popup}
+ * @type {?import('popup').PopupAny}
*/
get popup() {
return this._popup;
@@ -148,8 +162,8 @@ export class Frontend {
window.addEventListener('resize', this._onResize.bind(this), false);
DocumentUtil.addFullscreenChangeEventListener(this._updatePopup.bind(this));
- const visualViewport = window.visualViewport;
- if (visualViewport !== null && typeof visualViewport === 'object') {
+ const {visualViewport} = window;
+ if (typeof visualViewport !== 'undefined' && visualViewport !== null) {
visualViewport.addEventListener('scroll', this._onVisualViewportScroll.bind(this));
visualViewport.addEventListener('resize', this._onVisualViewportResize.bind(this));
}
@@ -172,7 +186,7 @@ export class Frontend {
this._prepareSiteSpecific();
this._updateContentScale();
- this._signalFrontendReady();
+ this._signalFrontendReady(null);
}
/**
@@ -186,7 +200,7 @@ export class Frontend {
/**
* Set or clear an override options context object.
- * @param {?object} optionsContext An options context object to use as the override, or `null` to clear the override.
+ * @param {?import('settings').OptionsContext} optionsContext An options context object to use as the override, or `null` to clear the override.
*/
setOptionsContextOverride(optionsContext) {
this._optionsContextOverride = optionsContext;
@@ -194,7 +208,7 @@ export class Frontend {
/**
* Performs a new search on a specific source.
- * @param {TextSourceRange|TextSourceElement} textSource The text source to search.
+ * @param {import('text-source').TextSource} textSource The text source to search.
*/
async setTextSource(textSource) {
this._textScanner.setCurrentTextSource(null);
@@ -216,7 +230,7 @@ export class Frontend {
/**
* Waits for the previous `showContent` call to be completed.
- * @returns {Promise} A promise which is resolved when the previous `showContent` call has completed.
+ * @returns {Promise<void>} A promise which is resolved when the previous `showContent` call has completed.
*/
showContentCompleted() {
return this._lastShowPromise;
@@ -224,45 +238,73 @@ export class Frontend {
// Message handlers
+ /**
+ * @param {import('frontend').FrontendRequestReadyBroadcastParams} params
+ */
_onMessageRequestFrontendReadyBroadcast({frameId}) {
this._signalFrontendReady(frameId);
}
// Action handlers
+ /**
+ * @returns {void}
+ */
_onActionScanSelectedText() {
this._scanSelectedText(false);
}
+ /**
+ * @returns {void}
+ */
_onActionScanTextAtCaret() {
this._scanSelectedText(true);
}
// API message handlers
+ /**
+ * @returns {string}
+ */
_onApiGetUrl() {
return window.location.href;
}
+ /**
+ * @returns {void}
+ */
_onApiClosePopup() {
this._clearSelection(false);
}
+ /**
+ * @returns {void}
+ */
_onApiCopySelection() {
// This will not work on Firefox if a popup has focus, which is usually the case when this function is called.
document.execCommand('copy');
}
+ /**
+ * @returns {string}
+ */
_onApiGetSelectionText() {
- return document.getSelection().toString();
+ const selection = document.getSelection();
+ return selection !== null ? selection.toString() : '';
}
+ /**
+ * @returns {import('frontend').GetPopupInfoResult}
+ */
_onApiGetPopupInfo() {
return {
popupId: (this._popup !== null ? this._popup.id : null)
};
}
+ /**
+ * @returns {{url: string, documentTitle: string}}
+ */
_onApiGetPageInfo() {
return {
url: window.location.href,
@@ -270,6 +312,10 @@ export class Frontend {
};
}
+ /**
+ * @param {{value: boolean, priority: number, awaitFrame: boolean}} params
+ * @returns {Promise<import('core').TokenString>}
+ */
async _onApiSetAllVisibleOverride({value, priority, awaitFrame}) {
const result = await this._popupFactory.setAllVisibleOverride(value, priority);
if (awaitFrame) {
@@ -278,45 +324,71 @@ export class Frontend {
return result;
}
+ /**
+ * @param {{token: import('core').TokenString}} params
+ * @returns {Promise<boolean>}
+ */
async _onApiClearAllVisibleOverride({token}) {
return await this._popupFactory.clearAllVisibleOverride(token);
}
// Private
+ /**
+ * @returns {void}
+ */
_onResize() {
this._updatePopupPosition();
}
+ /** @type {import('extension').ChromeRuntimeOnMessageCallback} */
_onRuntimeMessage({action, params}, sender, callback) {
const messageHandler = this._runtimeMessageHandlers.get(action);
if (typeof messageHandler === 'undefined') { return false; }
return invokeMessageHandler(messageHandler, params, callback, sender);
}
+ /**
+ * @param {{newZoomFactor: number}} params
+ */
_onZoomChanged({newZoomFactor}) {
this._pageZoomFactor = newZoomFactor;
this._updateContentScale();
}
+ /**
+ * @returns {void}
+ */
_onClosePopups() {
this._clearSelection(true);
}
+ /**
+ * @returns {void}
+ */
_onVisualViewportScroll() {
this._updatePopupPosition();
}
+ /**
+ * @returns {void}
+ */
_onVisualViewportResize() {
this._updateContentScale();
}
+ /**
+ * @returns {void}
+ */
_onTextScannerClear() {
this._clearSelection(false);
}
- _onSearched({type, dictionaryEntries, sentence, inputInfo: {eventType, passive, detail}, textSource, optionsContext, detail: {documentTitle}, error}) {
- const scanningOptions = this._options.scanning;
+ /**
+ * @param {import('text-scanner').SearchedEventDetails} details
+ */
+ _onSearched({type, dictionaryEntries, sentence, inputInfo: {eventType, passive, detail: inputInfoDetail}, textSource, optionsContext, detail, error}) {
+ const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning;
if (error !== null) {
if (yomitan.isExtensionUnloaded) {
@@ -326,34 +398,43 @@ export class Frontend {
} else {
log.error(error);
}
- } if (type !== null) {
+ } if (type !== null && optionsContext !== null) {
this._stopClearSelectionDelayed();
let focus = (eventType === 'mouseMove');
- if (isObject(detail)) {
- const focus2 = detail.focus;
+ if (typeof inputInfoDetail === 'object' && inputInfoDetail !== null) {
+ const focus2 = inputInfoDetail.focus;
if (typeof focus2 === 'boolean') { focus = focus2; }
}
- this._showContent(textSource, focus, dictionaryEntries, type, sentence, documentTitle, optionsContext);
+ this._showContent(textSource, focus, dictionaryEntries, type, sentence, detail !== null ? detail.documentTitle : null, optionsContext);
} else {
if (scanningOptions.autoHideResults) {
- this._clearSelectionDelayed(scanningOptions.hideDelay, false);
+ this._clearSelectionDelayed(scanningOptions.hideDelay, false, false);
}
}
}
+ /**
+ * @returns {void}
+ */
_onPopupFramePointerOver() {
this._isPointerOverPopup = true;
this._stopClearSelectionDelayed();
}
+ /**
+ * @returns {void}
+ */
_onPopupFramePointerOut() {
this._isPointerOverPopup = false;
- const scanningOptions = this._options.scanning;
+ const scanningOptions = /** @type {import('settings').ProfileOptions} */ (this._options).scanning;
if (scanningOptions.hidePopupOnCursorExit) {
- this._clearSelectionDelayed(scanningOptions.hidePopupOnCursorExitDelay, false);
+ this._clearSelectionDelayed(scanningOptions.hidePopupOnCursorExitDelay, false, false);
}
}
+ /**
+ * @param {boolean} passive
+ */
_clearSelection(passive) {
this._stopClearSelectionDelayed();
if (this._popup !== null) {
@@ -364,6 +445,11 @@ export class Frontend {
this._textScanner.clearSelection();
}
+ /**
+ * @param {number} delay
+ * @param {boolean} restart
+ * @param {boolean} passive
+ */
_clearSelectionDelayed(delay, restart, passive) {
if (!this._textScanner.hasSelection()) { return; }
if (delay > 0) {
@@ -379,6 +465,9 @@ export class Frontend {
}
}
+ /**
+ * @returns {void}
+ */
_stopClearSelectionDelayed() {
if (this._clearSelectionTimer !== null) {
clearTimeout(this._clearSelectionTimer);
@@ -386,6 +475,9 @@ export class Frontend {
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async _updateOptionsInternal() {
const optionsContext = await this._getOptionsContext();
const options = await yomitan.api.optionsGet(optionsContext);
@@ -426,12 +518,16 @@ export class Frontend {
await this._textScanner.searchLast();
}
+ /**
+ * @returns {Promise<void>}
+ */
async _updatePopup() {
- const {usePopupWindow, showIframePopupsInRootFrame} = this._options.general;
+ const {usePopupWindow, showIframePopupsInRootFrame} = /** @type {import('settings').ProfileOptions} */ (this._options).general;
const isIframe = !this._useProxyPopup && (window !== window.parent);
const currentPopup = this._popup;
+ /** @type {Promise<?import('popup').PopupAny>|undefined} */
let popupPromise;
if (usePopupWindow && this._canUseWindowPopup) {
popupPromise = this._popupCache.get('window');
@@ -466,6 +562,7 @@ export class Frontend {
// The token below is used as a unique identifier to ensure that a new _updatePopup call
// hasn't been started during the await.
+ /** @type {?import('core').TokenObject} */
const token = {};
this._updatePopupToken = token;
const popup = await popupPromise;
@@ -489,6 +586,9 @@ export class Frontend {
this._isPointerOverPopup = false;
}
+ /**
+ * @returns {Promise<?import('popup').PopupAny>}
+ */
async _getDefaultPopup() {
const isXmlDocument = (typeof XMLDocument !== 'undefined' && document instanceof XMLDocument);
if (isXmlDocument) {
@@ -502,6 +602,9 @@ export class Frontend {
});
}
+ /**
+ * @returns {Promise<import('popup').PopupAny>}
+ */
async _getProxyPopup() {
return await this._popupFactory.getOrCreatePopup({
frameId: this._parentFrameId,
@@ -511,6 +614,9 @@ export class Frontend {
});
}
+ /**
+ * @returns {Promise<?import('popup').PopupAny>}
+ */
async _getIframeProxyPopup() {
const targetFrameId = 0; // Root frameId
try {
@@ -520,7 +626,8 @@ export class Frontend {
return await this._getDefaultPopup();
}
- const {popupId} = await yomitan.crossFrame.invoke(targetFrameId, 'Frontend.getPopupInfo');
+ /** @type {import('frontend').GetPopupInfoResult} */
+ const {popupId} = await yomitan.crossFrame.invoke(targetFrameId, 'Frontend.getPopupInfo', {});
if (popupId === null) {
return null;
}
@@ -537,6 +644,9 @@ export class Frontend {
return popup;
}
+ /**
+ * @returns {Promise<import('popup').PopupAny>}
+ */
async _getPopupWindow() {
return await this._popupFactory.getOrCreatePopup({
depth: this._depth,
@@ -545,6 +655,9 @@ export class Frontend {
});
}
+ /**
+ * @returns {Element[]}
+ */
_ignoreElements() {
if (this._popup !== null) {
const container = this._popup.container;
@@ -555,6 +668,11 @@ export class Frontend {
return [];
}
+ /**
+ * @param {number} x
+ * @param {number} y
+ * @returns {Promise<boolean>}
+ */
async _ignorePoint(x, y) {
try {
return this._popup !== null && await this._popup.containsPoint(x, y);
@@ -566,17 +684,44 @@ export class Frontend {
}
}
+ /**
+ * @param {import('text-source').TextSource} textSource
+ */
_showExtensionUnloaded(textSource) {
- if (textSource === null) {
- textSource = this._textScanner.getCurrentTextSource();
- if (textSource === null) { return; }
- }
this._showPopupContent(textSource, null, null);
}
+ /**
+ * @param {import('text-source').TextSource} textSource
+ * @param {boolean} focus
+ * @param {?import('dictionary').DictionaryEntry[]} dictionaryEntries
+ * @param {import('display').PageType} type
+ * @param {?import('display').HistoryStateSentence} sentence
+ * @param {?string} documentTitle
+ * @param {import('settings').OptionsContext} optionsContext
+ */
_showContent(textSource, focus, dictionaryEntries, type, sentence, documentTitle, optionsContext) {
const query = textSource.text();
const {url} = optionsContext;
+ /** @type {import('display').HistoryState} */
+ const detailsState = {
+ focusEntry: 0,
+ optionsContext,
+ url
+ };
+ if (sentence !== null) { detailsState.sentence = sentence; }
+ if (documentTitle !== null) { detailsState.documentTitle = documentTitle; }
+ /** @type {import('display').HistoryContent} */
+ const detailsContent = {
+ contentOrigin: {
+ tabId: this._tabId,
+ frameId: this._frameId
+ }
+ };
+ if (dictionaryEntries !== null) {
+ detailsContent.dictionaryEntries = dictionaryEntries;
+ }
+ /** @type {import('display').ContentDetails} */
const details = {
focus,
historyMode: 'clear',
@@ -585,28 +730,22 @@ export class Frontend {
query,
wildcards: 'off'
},
- state: {
- focusEntry: 0,
- optionsContext,
- url,
- sentence,
- documentTitle
- },
- content: {
- dictionaryEntries,
- contentOrigin: {
- tabId: this._tabId,
- frameId: this._frameId
- }
- }
+ state: detailsState,
+ content: detailsContent
};
- if (textSource.type === 'element' && textSource.fullContent !== query) {
+ if (textSource instanceof TextSourceElement && textSource.fullContent !== query) {
details.params.full = textSource.fullContent;
details.params['full-visible'] = 'true';
}
this._showPopupContent(textSource, optionsContext, details);
}
+ /**
+ * @param {import('text-source').TextSource} textSource
+ * @param {?import('settings').OptionsContext} optionsContext
+ * @param {?import('display').ContentDetails} details
+ * @returns {Promise<void>}
+ */
_showPopupContent(textSource, optionsContext, details) {
const sourceRects = [];
for (const {left, top, right, bottom} of textSource.getRects()) {
@@ -631,6 +770,9 @@ export class Frontend {
return this._lastShowPromise;
}
+ /**
+ * @returns {void}
+ */
_updateTextScannerEnabled() {
const enabled = (this._options !== null && this._options.general.enable && !this._disabledOverride);
if (enabled === this._textScanner.isEnabled()) { return; }
@@ -643,15 +785,18 @@ export class Frontend {
}
}
+ /**
+ * @returns {void}
+ */
_updateContentScale() {
- const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = this._options.general;
+ const {popupScalingFactor, popupScaleRelativeToPageZoom, popupScaleRelativeToVisualViewport} = /** @type {import('settings').ProfileOptions} */ (this._options).general;
let contentScale = popupScalingFactor;
if (popupScaleRelativeToPageZoom) {
contentScale /= this._pageZoomFactor;
}
if (popupScaleRelativeToVisualViewport) {
- const visualViewport = window.visualViewport;
- const visualViewportScale = (visualViewport !== null && typeof visualViewport === 'object' ? visualViewport.scale : 1.0);
+ const {visualViewport} = window;
+ const visualViewportScale = (typeof visualViewport !== 'undefined' && visualViewport !== null ? visualViewport.scale : 1.0);
contentScale /= visualViewportScale;
}
if (contentScale === this._contentScale) { return; }
@@ -663,6 +808,9 @@ export class Frontend {
this._updatePopupPosition();
}
+ /**
+ * @returns {Promise<void>}
+ */
async _updatePopupPosition() {
const textSource = this._textScanner.getCurrentTextSource();
if (
@@ -674,7 +822,11 @@ export class Frontend {
}
}
- _signalFrontendReady(targetFrameId=null) {
+ /**
+ * @param {?number} targetFrameId
+ */
+ _signalFrontendReady(targetFrameId) {
+ /** @type {import('frontend').FrontendReadyDetails} */
const params = {frameId: this._frameId};
if (targetFrameId === null) {
yomitan.api.broadcastTab('frontendReady', params);
@@ -683,8 +835,14 @@ export class Frontend {
}
}
+ /**
+ * @param {number} frameId
+ * @param {?number} timeout
+ * @returns {Promise<void>}
+ */
async _waitForFrontendReady(frameId, timeout) {
return new Promise((resolve, reject) => {
+ /** @type {?number} */
let timeoutId = null;
const cleanup = () => {
@@ -694,10 +852,11 @@ export class Frontend {
}
chrome.runtime.onMessage.removeListener(onMessage);
};
- const onMessage = (message, sender, sendResponse) => {
+ /** @type {import('extension').ChromeRuntimeOnMessageCallback} */
+ const onMessage = (message, _sender, sendResponse) => {
try {
const {action, params} = message;
- if (action === 'frontendReady' && params.frameId === frameId) {
+ if (action === 'frontendReady' && /** @type {import('frontend').FrontendReadyDetails} */ (params).frameId === frameId) {
cleanup();
resolve();
sendResponse();
@@ -720,6 +879,10 @@ export class Frontend {
});
}
+ /**
+ * @param {import('settings').PreventMiddleMouseOptions} preventMiddleMouseOptions
+ * @returns {boolean}
+ */
_getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) {
switch (this._pageType) {
case 'web': return preventMiddleMouseOptions.onWebPages;
@@ -729,6 +892,9 @@ export class Frontend {
}
}
+ /**
+ * @returns {Promise<import('settings').OptionsContext>}
+ */
async _getOptionsContext() {
let optionsContext = this._optionsContextOverride;
if (optionsContext === null) {
@@ -737,10 +903,13 @@ export class Frontend {
return optionsContext;
}
+ /**
+ * @returns {Promise<{optionsContext: import('settings').OptionsContext, detail?: import('text-scanner').SearchResultDetail}>}
+ */
async _getSearchContext() {
let url = window.location.href;
let documentTitle = document.title;
- if (this._useProxyPopup) {
+ if (this._useProxyPopup && this._parentFrameId !== null) {
try {
({url, documentTitle} = await yomitan.crossFrame.invoke(this._parentFrameId, 'Frontend.getPageInfo', {}));
} catch (e) {
@@ -759,6 +928,10 @@ export class Frontend {
};
}
+ /**
+ * @param {boolean} allowEmptyRange
+ * @returns {Promise<boolean>}
+ */
async _scanSelectedText(allowEmptyRange) {
const range = this._getFirstSelectionRange(allowEmptyRange);
if (range === null) { return false; }
@@ -767,8 +940,13 @@ export class Frontend {
return true;
}
+ /**
+ * @param {boolean} allowEmptyRange
+ * @returns {?Range}
+ */
_getFirstSelectionRange(allowEmptyRange) {
const selection = window.getSelection();
+ if (selection === null) { return null; }
for (let i = 0, ii = selection.rangeCount; i < ii; ++i) {
const range = selection.getRangeAt(i);
if (range.toString().length > 0 || allowEmptyRange) {
@@ -778,6 +956,9 @@ export class Frontend {
return null;
}
+ /**
+ * @returns {void}
+ */
_prepareSiteSpecific() {
switch (location.hostname.toLowerCase()) {
case 'docs.google.com':
@@ -786,6 +967,9 @@ export class Frontend {
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async _prepareGoogleDocs() {
if (typeof GoogleDocsUtil !== 'undefined') { return; }
await yomitan.api.loadExtensionScripts([
diff --git a/ext/js/app/popup-factory.js b/ext/js/app/popup-factory.js
index e871f7ec..6fa50796 100644
--- a/ext/js/app/popup-factory.js
+++ b/ext/js/app/popup-factory.js
@@ -32,9 +32,13 @@ export class PopupFactory {
* @param {number} frameId The frame ID of the host frame.
*/
constructor(frameId) {
+ /** @type {number} */
this._frameId = frameId;
+ /** @type {FrameOffsetForwarder} */
this._frameOffsetForwarder = new FrameOffsetForwarder(frameId);
+ /** @type {Map<string, import('popup').PopupAny>} */
this._popups = new Map();
+ /** @type {Map<string, {popup: import('popup').PopupAny, token: string}[]>} */
this._allPopupVisibilityTokenMap = new Map();
}
@@ -46,17 +50,17 @@ export class PopupFactory {
yomitan.crossFrame.registerHandlers([
['PopupFactory.getOrCreatePopup', {async: true, handler: this._onApiGetOrCreatePopup.bind(this)}],
['PopupFactory.setOptionsContext', {async: true, handler: this._onApiSetOptionsContext.bind(this)}],
- ['PopupFactory.hide', {async: false, handler: this._onApiHide.bind(this)}],
+ ['PopupFactory.hide', {async: true, handler: this._onApiHide.bind(this)}],
['PopupFactory.isVisible', {async: true, handler: this._onApiIsVisibleAsync.bind(this)}],
['PopupFactory.setVisibleOverride', {async: true, handler: this._onApiSetVisibleOverride.bind(this)}],
['PopupFactory.clearVisibleOverride', {async: true, handler: this._onApiClearVisibleOverride.bind(this)}],
['PopupFactory.containsPoint', {async: true, handler: this._onApiContainsPoint.bind(this)}],
['PopupFactory.showContent', {async: true, handler: this._onApiShowContent.bind(this)}],
- ['PopupFactory.setCustomCss', {async: false, handler: this._onApiSetCustomCss.bind(this)}],
- ['PopupFactory.clearAutoPlayTimer', {async: false, handler: this._onApiClearAutoPlayTimer.bind(this)}],
- ['PopupFactory.setContentScale', {async: false, handler: this._onApiSetContentScale.bind(this)}],
- ['PopupFactory.updateTheme', {async: false, handler: this._onApiUpdateTheme.bind(this)}],
- ['PopupFactory.setCustomOuterCss', {async: false, handler: this._onApiSetCustomOuterCss.bind(this)}],
+ ['PopupFactory.setCustomCss', {async: true, handler: this._onApiSetCustomCss.bind(this)}],
+ ['PopupFactory.clearAutoPlayTimer', {async: true, handler: this._onApiClearAutoPlayTimer.bind(this)}],
+ ['PopupFactory.setContentScale', {async: true, handler: this._onApiSetContentScale.bind(this)}],
+ ['PopupFactory.updateTheme', {async: true, handler: this._onApiUpdateTheme.bind(this)}],
+ ['PopupFactory.setCustomOuterCss', {async: true, handler: this._onApiSetCustomOuterCss.bind(this)}],
['PopupFactory.getFrameSize', {async: true, handler: this._onApiGetFrameSize.bind(this)}],
['PopupFactory.setFrameSize', {async: true, handler: this._onApiSetFrameSize.bind(this)}]
]);
@@ -64,14 +68,8 @@ export class PopupFactory {
/**
* Gets or creates a popup based on a set of parameters
- * @param {object} details Details about how to acquire the popup.
- * @param {?number} [details.frameId] The ID of the frame that should host the popup.
- * @param {?string} [details.id] A specific ID used to find an existing popup, or to assign to the new popup.
- * @param {?string} [details.parentPopupId] The ID of the parent popup.
- * @param {?number} [details.depth] A specific depth value to assign to the popup.
- * @param {boolean} [details.popupWindow] Whether or not a separate popup window should be used, rather than an iframe.
- * @param {boolean} [details.childrenSupported] Whether or not the popup is able to show child popups.
- * @returns {Popup|PopupWindow|PopupProxy} The new or existing popup.
+ * @param {import('popup-factory').GetOrCreatePopupDetails} details Details about how to acquire the popup.
+ * @returns {Promise<import('popup').PopupAny>}
*/
async getOrCreatePopup({
frameId=null,
@@ -140,7 +138,7 @@ export class PopupFactory {
if (parent.child !== null) {
throw new Error('Parent popup already has a child');
}
- popup.parent = parent;
+ popup.parent = /** @type {Popup} */ (parent);
parent.child = popup;
}
this._popups.set(id, popup);
@@ -151,16 +149,18 @@ export class PopupFactory {
throw new Error('Invalid frameId');
}
const useFrameOffsetForwarder = (parentPopupId === null);
- ({id, depth, frameId} = await yomitan.crossFrame.invoke(frameId, 'PopupFactory.getOrCreatePopup', {
+ /** @type {{id: string, depth: number, frameId: number}} */
+ const info = await yomitan.crossFrame.invoke(frameId, 'PopupFactory.getOrCreatePopup', /** @type {import('popup-factory').GetOrCreatePopupDetails} */ ({
id,
parentPopupId,
frameId,
childrenSupported
}));
+ id = info.id;
const popup = new PopupProxy({
id,
- depth,
- frameId,
+ depth: info.depth,
+ frameId: info.frameId,
frameOffsetForwarder: useFrameOffsetForwarder ? this._frameOffsetForwarder : null
});
this._popups.set(id, popup);
@@ -172,24 +172,34 @@ export class PopupFactory {
* Force all popups to have a specific visibility value.
* @param {boolean} value Whether or not the popups should be visible.
* @param {number} priority The priority of the override.
- * @returns {string} A token which can be passed to clearAllVisibleOverride.
+ * @returns {Promise<import('core').TokenString>} A token which can be passed to clearAllVisibleOverride.
* @throws An exception is thrown if any popup fails to have its visibiltiy overridden.
*/
async setAllVisibleOverride(value, priority) {
const promises = [];
- const errors = [];
for (const popup of this._popups.values()) {
- const promise = popup.setVisibleOverride(value, priority)
- .then(
- (token) => ({popup, token}),
- (error) => { errors.push(error); return null; }
- );
+ const promise = this._setPopupVisibleOverrideReturnTuple(popup, value, priority);
promises.push(promise);
}
- const results = (await Promise.all(promises)).filter(({token}) => token !== null);
+ /** @type {undefined|unknown} */
+ let error = void 0;
+ /** @type {{popup: import('popup').PopupAny, token: string}[]} */
+ const results = [];
+ for (const promise of promises) {
+ try {
+ const {popup, token} = await promise;
+ if (token !== null) {
+ results.push({popup, token});
+ }
+ } catch (e) {
+ if (typeof error === 'undefined') {
+ error = new Error(`Failed to set popup visibility override: ${e}`);
+ }
+ }
+ }
- if (errors.length === 0) {
+ if (typeof error === 'undefined') {
const token = generateId(16);
this._allPopupVisibilityTokenMap.set(token, results);
return token;
@@ -197,13 +207,24 @@ export class PopupFactory {
// Revert on error
await this._revertPopupVisibilityOverrides(results);
- throw errors[0];
+ throw error;
+ }
+
+ /**
+ * @param {import('popup').PopupAny} popup
+ * @param {boolean} value
+ * @param {number} priority
+ * @returns {Promise<{popup: import('popup').PopupAny, token: ?string}>}
+ */
+ async _setPopupVisibleOverrideReturnTuple(popup, value, priority) {
+ const token = await popup.setVisibleOverride(value, priority);
+ return {popup, token};
}
/**
* Clears a visibility override that was generated by `setAllVisibleOverride`.
- * @param {string} token The token returned from `setAllVisibleOverride`.
- * @returns {boolean} `true` if the override existed and was removed, `false` otherwise.
+ * @param {import('core').TokenString} token The token returned from `setAllVisibleOverride`.
+ * @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
*/
async clearAllVisibleOverride(token) {
const results = this._allPopupVisibilityTokenMap.get(token);
@@ -216,6 +237,10 @@ export class PopupFactory {
// API message handlers
+ /**
+ * @param {import('popup-factory').GetOrCreatePopupDetails} details
+ * @returns {Promise<{id: string, depth: number, frameId: number}>}
+ */
async _onApiGetOrCreatePopup(details) {
const popup = await this.getOrCreatePopup(details);
return {
@@ -225,31 +250,53 @@ export class PopupFactory {
};
}
+ /**
+ * @param {{id: string, optionsContext: import('settings').OptionsContext}} params
+ */
async _onApiSetOptionsContext({id, optionsContext}) {
const popup = this._getPopup(id);
- return await popup.setOptionsContext(optionsContext);
+ await popup.setOptionsContext(optionsContext);
}
- _onApiHide({id, changeFocus}) {
+ /**
+ * @param {{id: string, changeFocus: boolean}} params
+ */
+ async _onApiHide({id, changeFocus}) {
const popup = this._getPopup(id);
- return popup.hide(changeFocus);
+ await popup.hide(changeFocus);
}
+ /**
+ * @param {{id: string}} params
+ * @returns {Promise<boolean>}
+ */
async _onApiIsVisibleAsync({id}) {
const popup = this._getPopup(id);
return await popup.isVisible();
}
+ /**
+ * @param {{id: string, value: boolean, priority: number}} params
+ * @returns {Promise<?import('core').TokenString>}
+ */
async _onApiSetVisibleOverride({id, value, priority}) {
const popup = this._getPopup(id);
return await popup.setVisibleOverride(value, priority);
}
+ /**
+ * @param {{id: string, token: import('core').TokenString}} params
+ * @returns {Promise<boolean>}
+ */
async _onApiClearVisibleOverride({id, token}) {
const popup = this._getPopup(id);
return await popup.clearVisibleOverride(token);
}
+ /**
+ * @param {{id: string, x: number, y: number}} params
+ * @returns {Promise<boolean>}
+ */
async _onApiContainsPoint({id, x, y}) {
const popup = this._getPopup(id);
const offset = this._getPopupOffset(popup);
@@ -258,6 +305,10 @@ export class PopupFactory {
return await popup.containsPoint(x, y);
}
+ /**
+ * @param {{id: string, details: import('popup').ContentDetails, displayDetails: ?import('display').ContentDetails}} params
+ * @returns {Promise<void>}
+ */
async _onApiShowContent({id, details, displayDetails}) {
const popup = this._getPopup(id);
if (!this._popupCanShow(popup)) { return; }
@@ -274,36 +325,64 @@ export class PopupFactory {
return await popup.showContent(details, displayDetails);
}
- _onApiSetCustomCss({id, css}) {
+ /**
+ * @param {{id: string, css: string}} params
+ * @returns {Promise<void>}
+ */
+ async _onApiSetCustomCss({id, css}) {
const popup = this._getPopup(id);
- return popup.setCustomCss(css);
+ await popup.setCustomCss(css);
}
- _onApiClearAutoPlayTimer({id}) {
+ /**
+ * @param {{id: string}} params
+ * @returns {Promise<void>}
+ */
+ async _onApiClearAutoPlayTimer({id}) {
const popup = this._getPopup(id);
- return popup.clearAutoPlayTimer();
+ await popup.clearAutoPlayTimer();
}
- _onApiSetContentScale({id, scale}) {
+ /**
+ * @param {{id: string, scale: number}} params
+ * @returns {Promise<void>}
+ */
+ async _onApiSetContentScale({id, scale}) {
const popup = this._getPopup(id);
- return popup.setContentScale(scale);
+ await popup.setContentScale(scale);
}
- _onApiUpdateTheme({id}) {
+ /**
+ * @param {{id: string}} params
+ * @returns {Promise<void>}
+ */
+ async _onApiUpdateTheme({id}) {
const popup = this._getPopup(id);
- return popup.updateTheme();
+ await popup.updateTheme();
}
- _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) {
+ /**
+ * @param {{id: string, css: string, useWebExtensionApi: boolean}} params
+ * @returns {Promise<void>}
+ */
+ async _onApiSetCustomOuterCss({id, css, useWebExtensionApi}) {
const popup = this._getPopup(id);
- return popup.setCustomOuterCss(css, useWebExtensionApi);
+ await popup.setCustomOuterCss(css, useWebExtensionApi);
}
+ /**
+ * @param {{id: string}} params
+ * @returns {Promise<import('popup').ValidSize>}
+ */
async _onApiGetFrameSize({id}) {
const popup = this._getPopup(id);
return await popup.getFrameSize();
}
+ /**
+ * @param {{id: string, width: number, height: number}} params
+ * @returns {Promise<boolean>}
+ */
async _onApiSetFrameSize({id, width, height}) {
const popup = this._getPopup(id);
return await popup.setFrameSize(width, height);
@@ -311,6 +390,11 @@ export class PopupFactory {
// Private functions
+ /**
+ * @param {string} id
+ * @returns {import('popup').PopupAny}
+ * @throws {Error}
+ */
_getPopup(id) {
const popup = this._popups.get(id);
if (typeof popup === 'undefined') {
@@ -319,6 +403,10 @@ export class PopupFactory {
return popup;
}
+ /**
+ * @param {import('popup').PopupAny} popup
+ * @returns {{x: number, y: number}}
+ */
_getPopupOffset(popup) {
const {parent} = popup;
if (parent !== null) {
@@ -330,11 +418,19 @@ export class PopupFactory {
return {x: 0, y: 0};
}
+ /**
+ * @param {import('popup').PopupAny} popup
+ * @returns {boolean}
+ */
_popupCanShow(popup) {
const parent = popup.parent;
return parent === null || parent.isVisibleSync();
}
+ /**
+ * @param {{popup: import('popup').PopupAny, token: string}[]} overrides
+ * @returns {Promise<boolean[]>}
+ */
async _revertPopupVisibilityOverrides(overrides) {
const promises = [];
for (const value of overrides) {
diff --git a/ext/js/app/popup-proxy.js b/ext/js/app/popup-proxy.js
index 3d8b55ba..9b5b0214 100644
--- a/ext/js/app/popup-proxy.js
+++ b/ext/js/app/popup-proxy.js
@@ -24,15 +24,12 @@ import {Popup} from './popup.js';
/**
* This class is a proxy for a Popup that is hosted in a different frame.
* It effectively forwards all API calls to the underlying Popup.
+ * @augments EventDispatcher<import('popup').PopupAnyEventType>
*/
export class PopupProxy extends EventDispatcher {
/**
* Creates a new instance.
- * @param {object} details Details about how to set up the instance.
- * @param {string} details.id The ID of the popup.
- * @param {number} details.depth The depth of the popup.
- * @param {number} details.frameId The ID of the host frame.
- * @param {FrameOffsetForwarder} details.frameOffsetForwarder A `FrameOffsetForwarder` instance which is used to determine frame positioning.
+ * @param {import('popup').PopupProxyConstructorDetails} details Details about how to set up the instance.
*/
constructor({
id,
@@ -41,15 +38,24 @@ export class PopupProxy extends EventDispatcher {
frameOffsetForwarder
}) {
super();
+ /** @type {string} */
this._id = id;
+ /** @type {number} */
this._depth = depth;
+ /** @type {number} */
this._frameId = frameId;
+ /** @type {?FrameOffsetForwarder} */
this._frameOffsetForwarder = frameOffsetForwarder;
+ /** @type {number} */
this._frameOffsetX = 0;
+ /** @type {number} */
this._frameOffsetY = 0;
+ /** @type {?Promise<?[x: number, y: number]>} */
this._frameOffsetPromise = null;
+ /** @type {?number} */
this._frameOffsetUpdatedAt = null;
+ /** @type {number} */
this._frameOffsetExpireTimeout = 1000;
}
@@ -64,7 +70,7 @@ export class PopupProxy extends EventDispatcher {
/**
* The parent of the popup, which is always `null` for `PopupProxy` instances,
* since any potential parent popups are in a different frame.
- * @type {Popup}
+ * @type {?Popup}
*/
get parent() {
return null;
@@ -82,7 +88,7 @@ export class PopupProxy extends EventDispatcher {
/**
* The popup child popup, which is always null for `PopupProxy` instances,
* since any potential child popups are in a different frame.
- * @type {Popup}
+ * @type {?Popup}
*/
get child() {
return null;
@@ -99,7 +105,7 @@ export class PopupProxy extends EventDispatcher {
/**
* The depth of the popup.
- * @type {numer}
+ * @type {number}
*/
get depth() {
return this._depth;
@@ -108,7 +114,7 @@ export class PopupProxy extends EventDispatcher {
/**
* Gets the content window of the frame. This value is null,
* since the window is hosted in a different frame.
- * @type {Window}
+ * @type {?Window}
*/
get frameContentWindow() {
return null;
@@ -116,7 +122,7 @@ export class PopupProxy extends EventDispatcher {
/**
* Gets the DOM node that contains the frame.
- * @type {Element}
+ * @type {?Element}
*/
get container() {
return null;
@@ -132,11 +138,11 @@ export class PopupProxy extends EventDispatcher {
/**
* Sets the options context for the popup.
- * @param {object} optionsContext The options context object.
+ * @param {import('settings').OptionsContext} optionsContext The options context object.
* @returns {Promise<void>}
*/
- setOptionsContext(optionsContext) {
- return this._invokeSafe('PopupFactory.setOptionsContext', {id: this._id, optionsContext});
+ async setOptionsContext(optionsContext) {
+ await this._invokeSafe('PopupFactory.setOptionsContext', {id: this._id, optionsContext}, void 0);
}
/**
@@ -144,8 +150,8 @@ export class PopupProxy extends EventDispatcher {
* @param {boolean} changeFocus Whether or not the parent popup or host frame should be focused.
* @returns {Promise<void>}
*/
- hide(changeFocus) {
- return this._invokeSafe('PopupFactory.hide', {id: this._id, changeFocus});
+ async hide(changeFocus) {
+ await this._invokeSafe('PopupFactory.hide', {id: this._id, changeFocus}, void 0);
}
/**
@@ -160,7 +166,7 @@ export class PopupProxy extends EventDispatcher {
* Force assigns the visibility of the popup.
* @param {boolean} value Whether or not the popup should be visible.
* @param {number} priority The priority of the override.
- * @returns {Promise<string?>} A token used which can be passed to `clearVisibleOverride`,
+ * @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`,
* or null if the override wasn't assigned.
*/
setVisibleOverride(value, priority) {
@@ -169,7 +175,7 @@ export class PopupProxy extends EventDispatcher {
/**
* Clears a visibility override that was generated by `setVisibleOverride`.
- * @param {string} token The token returned from `setVisibleOverride`.
+ * @param {import('core').TokenString} token The token returned from `setVisibleOverride`.
* @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
*/
clearVisibleOverride(token) {
@@ -193,8 +199,8 @@ export class PopupProxy extends EventDispatcher {
/**
* Shows and updates the positioning and content of the popup.
- * @param {Popup.ContentDetails} details Settings for the outer popup.
- * @param {Display.ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
+ * @param {import('popup').ContentDetails} details Settings for the outer popup.
+ * @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
* @returns {Promise<void>}
*/
async showContent(details, displayDetails) {
@@ -208,7 +214,7 @@ export class PopupProxy extends EventDispatcher {
sourceRect.bottom += this._frameOffsetY;
}
}
- return await this._invokeSafe('PopupFactory.showContent', {id: this._id, details, displayDetails});
+ await this._invokeSafe('PopupFactory.showContent', {id: this._id, details, displayDetails}, void 0);
}
/**
@@ -216,16 +222,16 @@ export class PopupProxy extends EventDispatcher {
* @param {string} css The CSS rules.
* @returns {Promise<void>}
*/
- setCustomCss(css) {
- return this._invokeSafe('PopupFactory.setCustomCss', {id: this._id, css});
+ async setCustomCss(css) {
+ await this._invokeSafe('PopupFactory.setCustomCss', {id: this._id, css}, void 0);
}
/**
* Stops the audio auto-play timer, if one has started.
* @returns {Promise<void>}
*/
- clearAutoPlayTimer() {
- return this._invokeSafe('PopupFactory.clearAutoPlayTimer', {id: this._id});
+ async clearAutoPlayTimer() {
+ await this._invokeSafe('PopupFactory.clearAutoPlayTimer', {id: this._id}, void 0);
}
/**
@@ -233,8 +239,8 @@ export class PopupProxy extends EventDispatcher {
* @param {number} scale The scaling factor.
* @returns {Promise<void>}
*/
- setContentScale(scale) {
- return this._invokeSafe('PopupFactory.setContentScale', {id: this._id, scale});
+ async setContentScale(scale) {
+ await this._invokeSafe('PopupFactory.setContentScale', {id: this._id, scale}, void 0);
}
/**
@@ -249,8 +255,8 @@ export class PopupProxy extends EventDispatcher {
* Updates the outer theme of the popup.
* @returns {Promise<void>}
*/
- updateTheme() {
- return this._invokeSafe('PopupFactory.updateTheme', {id: this._id});
+ async updateTheme() {
+ await this._invokeSafe('PopupFactory.updateTheme', {id: this._id}, void 0);
}
/**
@@ -260,13 +266,13 @@ export class PopupProxy extends EventDispatcher {
* When web extension APIs are used, a DOM node is not generated, making it harder to detect the changes.
* @returns {Promise<void>}
*/
- setCustomOuterCss(css, useWebExtensionApi) {
- return this._invokeSafe('PopupFactory.setCustomOuterCss', {id: this._id, css, useWebExtensionApi});
+ async setCustomOuterCss(css, useWebExtensionApi) {
+ await this._invokeSafe('PopupFactory.setCustomOuterCss', {id: this._id, css, useWebExtensionApi}, void 0);
}
/**
* Gets the rectangle of the DOM frame, synchronously.
- * @returns {Popup.ValidRect} The rect.
+ * @returns {import('popup').ValidRect} The rect.
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
*/
getFrameRect() {
@@ -275,7 +281,7 @@ export class PopupProxy extends EventDispatcher {
/**
* Gets the size of the DOM frame.
- * @returns {Promise<{width: number, height: number, valid: boolean}>} The size and whether or not it is valid.
+ * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid.
*/
getFrameSize() {
return this._invokeSafe('PopupFactory.getFrameSize', {id: this._id}, {width: 0, height: 0, valid: false});
@@ -288,16 +294,32 @@ export class PopupProxy extends EventDispatcher {
* @returns {Promise<boolean>} `true` if the size assignment was successful, `false` otherwise.
*/
setFrameSize(width, height) {
- return this._invokeSafe('PopupFactory.setFrameSize', {id: this._id, width, height});
+ return this._invokeSafe('PopupFactory.setFrameSize', {id: this._id, width, height}, false);
}
// Private
- _invoke(action, params={}) {
+ /**
+ * @template {import('core').SerializableObject} TParams
+ * @template [TReturn=unknown]
+ * @param {string} action
+ * @param {TParams} params
+ * @returns {Promise<TReturn>}
+ */
+ _invoke(action, params) {
return yomitan.crossFrame.invoke(this._frameId, action, params);
}
- async _invokeSafe(action, params={}, defaultReturnValue) {
+ /**
+ * @template {import('core').SerializableObject} TParams
+ * @template [TReturn=unknown]
+ * @template [TReturnDefault=unknown]
+ * @param {string} action
+ * @param {TParams} params
+ * @param {TReturnDefault} defaultReturnValue
+ * @returns {Promise<TReturn|TReturnDefault>}
+ */
+ async _invokeSafe(action, params, defaultReturnValue) {
try {
return await this._invoke(action, params);
} catch (e) {
@@ -306,10 +328,13 @@ export class PopupProxy extends EventDispatcher {
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async _updateFrameOffset() {
const now = Date.now();
const firstRun = this._frameOffsetUpdatedAt === null;
- const expired = firstRun || this._frameOffsetUpdatedAt < now - this._frameOffsetExpireTimeout;
+ const expired = firstRun || /** @type {number} */ (this._frameOffsetUpdatedAt) < now - this._frameOffsetExpireTimeout;
if (this._frameOffsetPromise === null && !expired) { return; }
if (this._frameOffsetPromise !== null) {
@@ -325,8 +350,11 @@ export class PopupProxy extends EventDispatcher {
}
}
+ /**
+ * @param {number} now
+ */
async _updateFrameOffsetInner(now) {
- this._frameOffsetPromise = this._frameOffsetForwarder.getOffset();
+ this._frameOffsetPromise = /** @type {FrameOffsetForwarder} */ (this._frameOffsetForwarder).getOffset();
try {
const offset = await this._frameOffsetPromise;
if (offset !== null) {
diff --git a/ext/js/app/popup-window.js b/ext/js/app/popup-window.js
index 88370684..af1ac1e4 100644
--- a/ext/js/app/popup-window.js
+++ b/ext/js/app/popup-window.js
@@ -22,14 +22,12 @@ import {Popup} from './popup.js';
/**
* This class represents a popup that is hosted in a new native window.
+ * @augments EventDispatcher<import('popup').PopupAnyEventType>
*/
export class PopupWindow extends EventDispatcher {
/**
* Creates a new instance.
- * @param {object} details Details about how to set up the instance.
- * @param {string} details.id The ID of the popup.
- * @param {number} details.depth The depth of the popup.
- * @param {number} details.frameId The ID of the host frame.
+ * @param {import('popup').PopupWindowConstructorDetails} details Details about how to set up the instance.
*/
constructor({
id,
@@ -37,9 +35,13 @@ export class PopupWindow extends EventDispatcher {
frameId
}) {
super();
+ /** @type {string} */
this._id = id;
+ /** @type {number} */
this._depth = depth;
+ /** @type {number} */
this._frameId = frameId;
+ /** @type {?number} */
this._popupTabId = null;
}
@@ -51,6 +53,9 @@ export class PopupWindow extends EventDispatcher {
return this._id;
}
+ /**
+ * @type {?Popup}
+ */
get parent() {
return null;
}
@@ -68,7 +73,7 @@ export class PopupWindow extends EventDispatcher {
/**
* The popup child popup, which is always null for `PopupWindow` instances,
* since any potential child popups are in a different frame.
- * @type {Popup}
+ * @type {?Popup}
*/
get child() {
return null;
@@ -85,7 +90,7 @@ export class PopupWindow extends EventDispatcher {
/**
* The depth of the popup.
- * @type {numer}
+ * @type {number}
*/
get depth() {
return this._depth;
@@ -94,7 +99,7 @@ export class PopupWindow extends EventDispatcher {
/**
* Gets the content window of the frame. This value is null,
* since the window is hosted in a different frame.
- * @type {Window}
+ * @type {?Window}
*/
get frameContentWindow() {
return null;
@@ -102,7 +107,7 @@ export class PopupWindow extends EventDispatcher {
/**
* Gets the DOM node that contains the frame.
- * @type {Element}
+ * @type {?Element}
*/
get container() {
return null;
@@ -118,11 +123,11 @@ export class PopupWindow extends EventDispatcher {
/**
* Sets the options context for the popup.
- * @param {object} optionsContext The options context object.
+ * @param {import('settings').OptionsContext} optionsContext The options context object.
* @returns {Promise<void>}
*/
- setOptionsContext(optionsContext) {
- return this._invoke(false, 'Display.setOptionsContext', {id: this._id, optionsContext});
+ async setOptionsContext(optionsContext) {
+ await this._invoke(false, 'Display.setOptionsContext', {id: this._id, optionsContext});
}
/**
@@ -145,7 +150,7 @@ export class PopupWindow extends EventDispatcher {
* Force assigns the visibility of the popup.
* @param {boolean} _value Whether or not the popup should be visible.
* @param {number} _priority The priority of the override.
- * @returns {Promise<string?>} A token used which can be passed to `clearVisibleOverride`,
+ * @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`,
* or null if the override wasn't assigned.
*/
async setVisibleOverride(_value, _priority) {
@@ -154,10 +159,10 @@ export class PopupWindow extends EventDispatcher {
/**
* Clears a visibility override that was generated by `setVisibleOverride`.
- * @param {string} _token The token returned from `setVisibleOverride`.
- * @returns {boolean} `true` if the override existed and was removed, `false` otherwise.
+ * @param {import('core').TokenString} _token The token returned from `setVisibleOverride`.
+ * @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
*/
- clearVisibleOverride(_token) {
+ async clearVisibleOverride(_token) {
return false;
}
@@ -173,8 +178,8 @@ export class PopupWindow extends EventDispatcher {
/**
* Shows and updates the positioning and content of the popup.
- * @param {Popup.ContentDetails} _details Settings for the outer popup.
- * @param {Display.ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
+ * @param {import('popup').ContentDetails} _details Settings for the outer popup.
+ * @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
* @returns {Promise<void>}
*/
async showContent(_details, displayDetails) {
@@ -187,23 +192,23 @@ export class PopupWindow extends EventDispatcher {
* @param {string} css The CSS rules.
* @returns {Promise<void>}
*/
- setCustomCss(css) {
- return this._invoke(false, 'Display.setCustomCss', {id: this._id, css});
+ async setCustomCss(css) {
+ await this._invoke(false, 'Display.setCustomCss', {id: this._id, css});
}
/**
* Stops the audio auto-play timer, if one has started.
* @returns {Promise<void>}
*/
- clearAutoPlayTimer() {
- return this._invoke(false, 'Display.clearAutoPlayTimer', {id: this._id});
+ async clearAutoPlayTimer() {
+ await this._invoke(false, 'Display.clearAutoPlayTimer', {id: this._id});
}
/**
* Sets the scaling factor of the popup content.
* @param {number} _scale The scaling factor.
*/
- setContentScale(_scale) {
+ async setContentScale(_scale) {
// NOP
}
@@ -235,7 +240,7 @@ export class PopupWindow extends EventDispatcher {
/**
* Gets the rectangle of the DOM frame, synchronously.
- * @returns {Popup.ValidRect} The rect.
+ * @returns {import('popup').ValidRect} The rect.
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
*/
getFrameRect() {
@@ -244,7 +249,7 @@ export class PopupWindow extends EventDispatcher {
/**
* Gets the size of the DOM frame.
- * @returns {Promise<{width: number, height: number, valid: boolean}>} The size and whether or not it is valid.
+ * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid.
*/
async getFrameSize() {
return {width: 0, height: 0, valid: false};
@@ -262,9 +267,17 @@ export class PopupWindow extends EventDispatcher {
// Private
- async _invoke(open, action, params={}, defaultReturnValue) {
+ /**
+ * @template {import('core').SerializableObject} TParams
+ * @template [TReturn=unknown]
+ * @param {boolean} open
+ * @param {string} action
+ * @param {TParams} params
+ * @returns {Promise<TReturn|undefined>}
+ */
+ async _invoke(open, action, params) {
if (yomitan.isExtensionUnloaded) {
- return defaultReturnValue;
+ return void 0;
}
const frameId = 0;
@@ -280,7 +293,7 @@ export class PopupWindow extends EventDispatcher {
}
if (!open) {
- return defaultReturnValue;
+ return void 0;
}
const {tabId} = await yomitan.api.getOrCreateSearchPopup({focus: 'ifCreated'});
diff --git a/ext/js/app/popup.js b/ext/js/app/popup.js
index 0e2e2493..31b18f01 100644
--- a/ext/js/app/popup.js
+++ b/ext/js/app/popup.js
@@ -25,53 +25,12 @@ import {ThemeController} from './theme-controller.js';
/**
* This class is the container which hosts the display of search results.
+ * @augments EventDispatcher<import('popup').PopupAnyEventType>
*/
export class Popup extends EventDispatcher {
/**
- * Information about how popup content should be shown, specifically related to the outer popup frame.
- * @typedef {object} ContentDetails
- * @property {?object} optionsContext The options context for the content to show.
- * @property {Rect[]} sourceRects The rectangles of the source content.
- * @property {'horizontal-tb' | 'vertical-rl' | 'vertical-lr' | 'sideways-rl' | 'sideways-lr'} writingMode The normalized CSS writing-mode value of the source content.
- */
-
- /**
- * A rectangle representing a DOM region, similar to DOMRect.
- * @typedef {object} Rect
- * @property {number} left The left position of the rectangle.
- * @property {number} top The top position of the rectangle.
- * @property {number} right The right position of the rectangle.
- * @property {number} bottom The bottom position of the rectangle.
- */
-
- /**
- * A rectangle representing a DOM region, similar to DOMRect but with a `valid` property.
- * @typedef {object} ValidRect
- * @property {number} left The left position of the rectangle.
- * @property {number} top The top position of the rectangle.
- * @property {number} right The right position of the rectangle.
- * @property {number} bottom The bottom position of the rectangle.
- * @property {boolean} valid Whether or not the rectangle is valid.
- */
-
- /**
- * A rectangle representing a DOM region for placing the popup frame.
- * @typedef {object} SizeRect
- * @property {number} left The left position of the rectangle.
- * @property {number} top The top position of the rectangle.
- * @property {number} width The width of the rectangle.
- * @property {number} height The height of the rectangle.
- * @property {boolean} after Whether or not the rectangle is positioned to the right of the source rectangle.
- * @property {boolean} below Whether or not the rectangle is positioned below the source rectangle.
- */
-
- /**
* Creates a new instance.
- * @param {object} details The details used to construct the new instance.
- * @param {string} details.id The ID of the popup.
- * @param {number} details.depth The depth of the popup.
- * @param {number} details.frameId The ID of the host frame.
- * @param {boolean} details.childrenSupported Whether or not the popup is able to show child popups.
+ * @param {import('popup').PopupConstructorDetails} details The details used to construct the new instance.
*/
constructor({
id,
@@ -80,48 +39,83 @@ export class Popup extends EventDispatcher {
childrenSupported
}) {
super();
+ /** @type {string} */
this._id = id;
+ /** @type {number} */
this._depth = depth;
+ /** @type {number} */
this._frameId = frameId;
+ /** @type {boolean} */
this._childrenSupported = childrenSupported;
+ /** @type {?Popup} */
this._parent = null;
+ /** @type {?Popup} */
this._child = null;
+ /** @type {?Promise<boolean>} */
this._injectPromise = null;
+ /** @type {boolean} */
this._injectPromiseComplete = false;
+ /** @type {DynamicProperty<boolean>} */
this._visible = new DynamicProperty(false);
+ /** @type {boolean} */
this._visibleValue = false;
+ /** @type {?import('settings').OptionsContext} */
this._optionsContext = null;
+ /** @type {number} */
this._contentScale = 1.0;
+ /** @type {string} */
this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, '');
- this._optionsAssigned = false;
+ /** @type {number} */
this._initialWidth = 400;
+ /** @type {number} */
this._initialHeight = 250;
+ /** @type {number} */
this._horizontalOffset = 0;
+ /** @type {number} */
this._verticalOffset = 10;
+ /** @type {number} */
this._horizontalOffset2 = 10;
+ /** @type {number} */
this._verticalOffset2 = 0;
+ /** @type {import('settings').PopupVerticalTextPosition} */
this._verticalTextPosition = 'before';
+ /** @type {boolean} */
this._horizontalTextPositionBelow = true;
+ /** @type {import('settings').PopupDisplayMode} */
this._displayMode = 'default';
+ /** @type {boolean} */
this._displayModeIsFullWidth = false;
+ /** @type {boolean} */
this._scaleRelativeToVisualViewport = true;
+ /** @type {boolean} */
this._useSecureFrameUrl = true;
+ /** @type {boolean} */
this._useShadowDom = true;
+ /** @type {string} */
this._customOuterCss = '';
+ /** @type {?number} */
this._frameSizeContentScale = null;
+ /** @type {?FrameClient} */
this._frameClient = null;
+ /** @type {HTMLIFrameElement} */
this._frame = document.createElement('iframe');
this._frame.className = 'yomitan-popup';
this._frame.style.width = '0';
this._frame.style.height = '0';
+ /** @type {boolean} */
+ this._frameConnected = false;
+ /** @type {HTMLElement} */
this._container = this._frame;
+ /** @type {?ShadowRoot} */
this._shadow = null;
+ /** @type {ThemeController} */
this._themeController = new ThemeController(this._frame);
+ /** @type {EventListenerCollection} */
this._fullscreenEventListeners = new EventListenerCollection();
}
@@ -135,7 +129,7 @@ export class Popup extends EventDispatcher {
/**
* The parent of the popup.
- * @type {Popup}
+ * @type {?Popup}
*/
get parent() {
return this._parent;
@@ -151,7 +145,7 @@ export class Popup extends EventDispatcher {
/**
* The child of the popup.
- * @type {Popup}
+ * @type {?Popup}
*/
get child() {
return this._child;
@@ -167,7 +161,7 @@ export class Popup extends EventDispatcher {
/**
* The depth of the popup.
- * @type {numer}
+ * @type {number}
*/
get depth() {
return this._depth;
@@ -215,11 +209,13 @@ export class Popup extends EventDispatcher {
/**
* Sets the options context for the popup.
- * @param {object} optionsContext The options context object.
+ * @param {import('settings').OptionsContext} optionsContext The options context object.
*/
async setOptionsContext(optionsContext) {
await this._setOptionsContext(optionsContext);
- await this._invokeSafe('Display.setOptionsContext', {optionsContext});
+ if (this._frameConnected) {
+ await this._invokeSafe('Display.setOptionsContext', {optionsContext});
+ }
}
/**
@@ -252,7 +248,7 @@ export class Popup extends EventDispatcher {
* Force assigns the visibility of the popup.
* @param {boolean} value Whether or not the popup should be visible.
* @param {number} priority The priority of the override.
- * @returns {Promise<string?>} A token used which can be passed to `clearVisibleOverride`,
+ * @returns {Promise<?import('core').TokenString>} A token used which can be passed to `clearVisibleOverride`,
* or null if the override wasn't assigned.
*/
async setVisibleOverride(value, priority) {
@@ -261,7 +257,7 @@ export class Popup extends EventDispatcher {
/**
* Clears a visibility override that was generated by `setVisibleOverride`.
- * @param {string} token The token returned from `setVisibleOverride`.
+ * @param {import('core').TokenString} token The token returned from `setVisibleOverride`.
* @returns {Promise<boolean>} `true` if the override existed and was removed, `false` otherwise.
*/
async clearVisibleOverride(token) {
@@ -275,7 +271,8 @@ export class Popup extends EventDispatcher {
* @returns {Promise<boolean>} `true` if the point is contained within the popup's rect, `false` otherwise.
*/
async containsPoint(x, y) {
- for (let popup = this; popup !== null && popup.isVisibleSync(); popup = popup.child) {
+ // eslint-disable-next-line @typescript-eslint/no-this-alias
+ for (let popup = /** @type {?Popup} */ (this); popup !== null && popup.isVisibleSync(); popup = popup.child) {
const rect = popup.getFrameRect();
if (rect.valid && x >= rect.left && y >= rect.top && x < rect.right && y < rect.bottom) {
return true;
@@ -286,12 +283,12 @@ export class Popup extends EventDispatcher {
/**
* Shows and updates the positioning and content of the popup.
- * @param {ContentDetails} details Settings for the outer popup.
- * @param {Display.ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
+ * @param {import('popup').ContentDetails} details Settings for the outer popup.
+ * @param {?import('display').ContentDetails} displayDetails The details parameter passed to `Display.setContent`.
* @returns {Promise<void>}
*/
async showContent(details, displayDetails) {
- if (!this._optionsAssigned) { throw new Error('Options not assigned'); }
+ if (this._optionsContext === null) { throw new Error('Options not assigned'); }
const {optionsContext, sourceRects, writingMode} = details;
if (optionsContext !== null) {
@@ -309,25 +306,27 @@ export class Popup extends EventDispatcher {
* Sets the custom styles for the popup content.
* @param {string} css The CSS rules.
*/
- setCustomCss(css) {
- this._invokeSafe('Display.setCustomCss', {css});
+ async setCustomCss(css) {
+ await this._invokeSafe('Display.setCustomCss', {css});
}
/**
* Stops the audio auto-play timer, if one has started.
*/
- clearAutoPlayTimer() {
- this._invokeSafe('Display.clearAutoPlayTimer');
+ async clearAutoPlayTimer() {
+ if (this._frameConnected) {
+ await this._invokeSafe('Display.clearAutoPlayTimer', {});
+ }
}
/**
* Sets the scaling factor of the popup content.
* @param {number} scale The scaling factor.
*/
- setContentScale(scale) {
+ async setContentScale(scale) {
this._contentScale = scale;
this._frame.style.fontSize = `${scale}px`;
- this._invokeSafe('Display.setContentScale', {scale});
+ await this._invokeSafe('Display.setContentScale', {scale});
}
/**
@@ -360,12 +359,14 @@ export class Popup extends EventDispatcher {
parentNode = this._shadow;
}
const node = await dynamicLoader.loadStyle('yomitan-popup-outer-user-stylesheet', 'code', css, useWebExtensionApi, parentNode);
- this.trigger('customOuterCssChanged', {node, useWebExtensionApi, inShadow});
+ /** @type {import('popup').CustomOuterCssChangedEvent} */
+ const event = {node, useWebExtensionApi, inShadow};
+ this.trigger('customOuterCssChanged', event);
}
/**
* Gets the rectangle of the DOM frame, synchronously.
- * @returns {ValidRect} The rect.
+ * @returns {import('popup').ValidRect} The rect.
* `valid` is `false` for `PopupProxy`, since the DOM node is hosted in a different frame.
*/
getFrameRect() {
@@ -375,7 +376,7 @@ export class Popup extends EventDispatcher {
/**
* Gets the size of the DOM frame.
- * @returns {Promise<{width: number, height: number, valid: boolean}>} The size and whether or not it is valid.
+ * @returns {Promise<import('popup').ValidSize>} The size and whether or not it is valid.
*/
async getFrameSize() {
const {width, height} = this._getFrameBoundingClientRect();
@@ -395,14 +396,23 @@ export class Popup extends EventDispatcher {
// Private functions
+ /**
+ * @returns {void}
+ */
_onFrameMouseOver() {
this.trigger('framePointerOver', {});
}
+ /**
+ * @returns {void}
+ */
_onFrameMouseOut() {
this.trigger('framePointerOut', {});
}
+ /**
+ * @returns {Promise<boolean>}
+ */
_inject() {
let injectPromise = this._injectPromise;
if (injectPromise === null) {
@@ -419,19 +429,25 @@ export class Popup extends EventDispatcher {
return injectPromise;
}
+ /**
+ * @returns {Promise<boolean>}
+ */
async _injectInner1() {
try {
await this._injectInner2();
return true;
} catch (e) {
this._resetFrame();
- if (e.source === this) { return false; } // Passive error
+ if (e instanceof PopupError && e.source === this) { return false; } // Passive error
throw e;
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async _injectInner2() {
- if (!this._optionsAssigned) {
+ if (this._optionsContext === null) {
throw new Error('Options not initialized');
}
@@ -439,6 +455,7 @@ export class Popup extends EventDispatcher {
await this._setUpContainer(this._useShadowDom);
+ /** @type {import('frame-client').SetupFrameFunction} */
const setupFrame = (frame) => {
frame.removeAttribute('src');
frame.removeAttribute('srcdoc');
@@ -447,9 +464,8 @@ export class Popup extends EventDispatcher {
const {contentDocument} = frame;
if (contentDocument === null) {
// This can occur when running inside a sandboxed frame without "allow-same-origin"
- const error = new Error('Popup not supoprted in this context');
- error.source = this; // Used to detect a passive error which should be ignored
- throw error;
+ // Custom error is used to detect a passive error which should be ignored
+ throw new PopupError('Popup not supported in this context', this);
}
const url = chrome.runtime.getURL('/popup.html');
if (useSecurePopupFrameUrl) {
@@ -462,23 +478,32 @@ export class Popup extends EventDispatcher {
const frameClient = new FrameClient();
this._frameClient = frameClient;
await frameClient.connect(this._frame, this._targetOrigin, this._frameId, setupFrame);
+ this._frameConnected = true;
// Configure
- await this._invokeSafe('Display.configure', {
+ /** @type {import('display').ConfigureMessageDetails} */
+ const configureParams = {
depth: this._depth,
parentPopupId: this._id,
parentFrameId: this._frameId,
childrenSupported: this._childrenSupported,
scale: this._contentScale,
optionsContext: this._optionsContext
- });
+ };
+ await this._invokeSafe('Display.configure', configureParams);
}
+ /**
+ * @returns {void}
+ */
_onFrameLoad() {
if (!this._injectPromiseComplete) { return; }
this._resetFrame();
}
+ /**
+ * @returns {void}
+ */
_resetFrame() {
const parent = this._container.parentNode;
if (parent !== null) {
@@ -488,10 +513,14 @@ export class Popup extends EventDispatcher {
this._frame.removeAttribute('srcdoc');
this._frameClient = null;
+ this._frameConnected = false;
this._injectPromise = null;
this._injectPromiseComplete = false;
}
+ /**
+ * @param {boolean} usePopupShadowDom
+ */
async _setUpContainer(usePopupShadowDom) {
if (usePopupShadowDom && typeof this._frame.attachShadow === 'function') {
const container = document.createElement('div');
@@ -514,6 +543,9 @@ export class Popup extends EventDispatcher {
await this._injectStyles();
}
+ /**
+ * @returns {Promise<void>}
+ */
async _injectStyles() {
try {
await this._injectPopupOuterStylesheet();
@@ -528,7 +560,11 @@ export class Popup extends EventDispatcher {
}
}
+ /**
+ * @returns {Promise<void>}
+ */
async _injectPopupOuterStylesheet() {
+ /** @type {'code'|'file'|'file-content'} */
let fileType = 'file';
let useWebExtensionApi = true;
let parentNode = null;
@@ -540,6 +576,9 @@ export class Popup extends EventDispatcher {
await dynamicLoader.loadStyle('yomitan-popup-outer-stylesheet', fileType, '/css/popup-outer.css', useWebExtensionApi, parentNode);
}
+ /**
+ * @param {boolean} observe
+ */
_observeFullscreen(observe) {
if (!observe) {
this._fullscreenEventListeners.removeAllEventListeners();
@@ -554,6 +593,9 @@ export class Popup extends EventDispatcher {
DocumentUtil.addFullscreenChangeEventListener(this._onFullscreenChanged.bind(this), this._fullscreenEventListeners);
}
+ /**
+ * @returns {void}
+ */
_onFullscreenChanged() {
const parent = this._getFrameParentElement();
if (parent !== null && this._container.parentNode !== parent) {
@@ -561,6 +603,10 @@ export class Popup extends EventDispatcher {
}
}
+ /**
+ * @param {import('popup').Rect[]} sourceRects
+ * @param {import('document-util').NormalizedWritingMode} writingMode
+ */
async _show(sourceRects, writingMode) {
const injected = await this._inject();
if (!injected) { return; }
@@ -588,16 +634,26 @@ export class Popup extends EventDispatcher {
}
}
+ /**
+ * @param {number} width
+ * @param {number} height
+ */
_setFrameSize(width, height) {
const {style} = this._frame;
style.width = `${width}px`;
style.height = `${height}px`;
}
+ /**
+ * @param {boolean} visible
+ */
_setVisible(visible) {
this._visible.defaultValue = visible;
}
+ /**
+ * @param {import('dynamic-property').ChangeEventDetails<boolean>} event
+ */
_onVisibleChange({value}) {
if (this._visibleValue === value) { return; }
this._visibleValue = value;
@@ -605,6 +661,9 @@ export class Popup extends EventDispatcher {
this._invokeSafe('Display.visibilityChanged', {value});
}
+ /**
+ * @returns {void}
+ */
_focusParent() {
if (this._parent !== null) {
// Chrome doesn't like focusing iframe without contentWindow.
@@ -621,23 +680,43 @@ export class Popup extends EventDispatcher {
}
}
- async _invoke(action, params={}) {
+ /**
+ * @template {import('core').SerializableObject} TParams
+ * @template [TReturn=unknown]
+ * @param {string} action
+ * @param {TParams} params
+ * @returns {Promise<TReturn>}
+ */
+ async _invoke(action, params) {
const contentWindow = this._frame.contentWindow;
- if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
+ if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) {
+ throw new Error(`Failed to invoke action ${action}: frame state invalid`);
+ }
const message = this._frameClient.createMessage({action, params});
return await yomitan.crossFrame.invoke(this._frameClient.frameId, 'popupMessage', message);
}
- async _invokeSafe(action, params={}, defaultReturnValue) {
+ /**
+ * @template {import('core').SerializableObject} TParams
+ * @template [TReturn=unknown]
+ * @param {string} action
+ * @param {TParams} params
+ * @returns {Promise<TReturn|undefined>}
+ */
+ async _invokeSafe(action, params) {
try {
return await this._invoke(action, params);
} catch (e) {
if (!yomitan.isExtensionUnloaded) { throw e; }
- return defaultReturnValue;
+ return void 0;
}
}
+ /**
+ * @param {string} action
+ * @param {import('core').SerializableObject} params
+ */
_invokeWindow(action, params={}) {
const contentWindow = this._frame.contentWindow;
if (this._frameClient === null || !this._frameClient.isConnected() || contentWindow === null) { return; }
@@ -646,10 +725,16 @@ export class Popup extends EventDispatcher {
contentWindow.postMessage(message, this._targetOrigin);
}
+ /**
+ * @returns {void}
+ */
_onExtensionUnloaded() {
this._invokeWindow('Display.extensionUnloaded');
}
+ /**
+ * @returns {Element}
+ */
_getFrameParentElement() {
let defaultParent = document.body;
if (defaultParent !== null && defaultParent.tagName.toLowerCase() === 'frameset') {
@@ -659,7 +744,8 @@ export class Popup extends EventDispatcher {
if (
fullscreenElement === null ||
fullscreenElement.shadowRoot ||
- fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions
+ // @ts-ignore - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions
+ fullscreenElement.openOrClosedShadowRoot
) {
return defaultParent;
}
@@ -675,10 +761,10 @@ export class Popup extends EventDispatcher {
/**
* Computes the position where the popup should be placed relative to the source content.
- * @param {Rect[]} sourceRects The rectangles of the source content.
- * @param {string} writingMode The CSS writing mode of the source text.
- * @param {Rect} viewport The viewport that the popup can be placed within.
- * @returns {SizeRect} The calculated rectangle for where to position the popup.
+ * @param {import('popup').Rect[]} sourceRects The rectangles of the source content.
+ * @param {import('document-util').NormalizedWritingMode} writingMode The CSS writing mode of the source text.
+ * @param {import('popup').Rect} viewport The viewport that the popup can be placed within.
+ * @returns {import('popup').SizeRect} The calculated rectangle for where to position the popup.
*/
_getPosition(sourceRects, writingMode, viewport) {
sourceRects = this._convertSourceRectsCoordinateSpace(sourceRects);
@@ -705,6 +791,7 @@ export class Popup extends EventDispatcher {
horizontalOffset *= contentScale;
verticalOffset *= contentScale;
+ /** @type {?import('popup').SizeRect} */
let best = null;
const sourceRectsLength = sourceRects.length;
for (let i = 0, ii = (sourceRectsLength > 1 ? sourceRectsLength : 0); i <= ii; ++i) {
@@ -720,19 +807,20 @@ export class Popup extends EventDispatcher {
if (result.height >= frameHeight) { break; }
}
}
- return best;
+ // Given the loop conditions, this is guaranteed to be non-null
+ return /** @type {import('popup').SizeRect} */ (best);
}
/**
* Computes the position where the popup should be placed for horizontal text.
- * @param {Rect} sourceRect The rectangle of the source content.
+ * @param {import('popup').Rect} sourceRect The rectangle of the source content.
* @param {number} frameWidth The preferred width of the frame.
* @param {number} frameHeight The preferred height of the frame.
- * @param {Rect} viewport The viewport that the frame can be placed within.
+ * @param {import('popup').Rect} viewport The viewport that the frame can be placed within.
* @param {number} horizontalOffset The horizontal offset from the source rect that the popup will be placed.
* @param {number} verticalOffset The vertical offset from the source rect that the popup will be placed.
* @param {boolean} preferBelow Whether or not the popup is preferred to be placed below the source content.
- * @returns {SizeRect} The calculated rectangle for where to position the popup.
+ * @returns {import('popup').SizeRect} The calculated rectangle for where to position the popup.
*/
_getPositionForHorizontalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferBelow) {
const [left, width, after] = this._getConstrainedPosition(
@@ -756,14 +844,14 @@ export class Popup extends EventDispatcher {
/**
* Computes the position where the popup should be placed for vertical text.
- * @param {Rect} sourceRect The rectangle of the source content.
+ * @param {import('popup').Rect} sourceRect The rectangle of the source content.
* @param {number} frameWidth The preferred width of the frame.
* @param {number} frameHeight The preferred height of the frame.
- * @param {Rect} viewport The viewport that the frame can be placed within.
+ * @param {import('popup').Rect} viewport The viewport that the frame can be placed within.
* @param {number} horizontalOffset The horizontal offset from the source rect that the popup will be placed.
* @param {number} verticalOffset The vertical offset from the source rect that the popup will be placed.
* @param {boolean} preferRight Whether or not the popup is preferred to be placed to the right of the source content.
- * @returns {SizeRect} The calculated rectangle for where to position the popup.
+ * @returns {import('popup').SizeRect} The calculated rectangle for where to position the popup.
*/
_getPositionForVerticalText(sourceRect, frameWidth, frameHeight, viewport, horizontalOffset, verticalOffset, preferRight) {
const [left, width, after] = this._getConstrainedPositionBinary(
@@ -785,6 +873,11 @@ export class Popup extends EventDispatcher {
return {left, top, width, height, after, below};
}
+ /**
+ * @param {import('settings').PopupVerticalTextPosition} positionPreference
+ * @param {import('document-util').NormalizedWritingMode} writingMode
+ * @returns {boolean}
+ */
_isVerticalTextPopupOnRight(positionPreference, writingMode) {
switch (positionPreference) {
case 'before':
@@ -799,6 +892,10 @@ export class Popup extends EventDispatcher {
}
}
+ /**
+ * @param {import('document-util').NormalizedWritingMode} writingMode
+ * @returns {boolean}
+ */
_isWritingModeLeftToRight(writingMode) {
switch (writingMode) {
case 'vertical-lr':
@@ -809,6 +906,15 @@ export class Popup extends EventDispatcher {
}
}
+ /**
+ * @param {number} positionBefore
+ * @param {number} positionAfter
+ * @param {number} size
+ * @param {number} minLimit
+ * @param {number} maxLimit
+ * @param {boolean} after
+ * @returns {[position: number, size: number, after: boolean]}
+ */
_getConstrainedPosition(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
size = Math.min(size, maxLimit - minLimit);
@@ -824,6 +930,15 @@ export class Popup extends EventDispatcher {
return [position, size, after];
}
+ /**
+ * @param {number} positionBefore
+ * @param {number} positionAfter
+ * @param {number} size
+ * @param {number} minLimit
+ * @param {number} maxLimit
+ * @param {boolean} after
+ * @returns {[position: number, size: number, after: boolean]}
+ */
_getConstrainedPositionBinary(positionBefore, positionAfter, size, minLimit, maxLimit, after) {
const overflowBefore = minLimit - (positionBefore - size);
const overflowAfter = (positionAfter + size) - maxLimit;
@@ -847,11 +962,11 @@ export class Popup extends EventDispatcher {
/**
* Gets the visual viewport.
* @param {boolean} useVisualViewport Whether or not the `window.visualViewport` should be used.
- * @returns {Rect} The rectangle of the visual viewport.
+ * @returns {import('popup').Rect} The rectangle of the visual viewport.
*/
_getViewport(useVisualViewport) {
- const visualViewport = window.visualViewport;
- if (visualViewport !== null && typeof visualViewport === 'object') {
+ const {visualViewport} = window;
+ if (typeof visualViewport !== 'undefined' && visualViewport !== null) {
const left = visualViewport.offsetLeft;
const top = visualViewport.offsetTop;
const width = visualViewport.width;
@@ -882,6 +997,9 @@ export class Popup extends EventDispatcher {
};
}
+ /**
+ * @param {import('settings').OptionsContext} optionsContext
+ */
async _setOptionsContext(optionsContext) {
this._optionsContext = optionsContext;
const options = await yomitan.api.optionsGet(optionsContext);
@@ -902,10 +1020,12 @@ export class Popup extends EventDispatcher {
this._useSecureFrameUrl = general.useSecurePopupFrameUrl;
this._useShadowDom = general.usePopupShadowDom;
this._customOuterCss = general.customPopupOuterCss;
- this._optionsAssigned = true;
this.updateTheme();
}
+ /**
+ * @param {import('settings').OptionsContext} optionsContext
+ */
async _setOptionsContextIfDifferent(optionsContext) {
if (deepEqual(this._optionsContext, optionsContext)) { return; }
await this._setOptionsContext(optionsContext);
@@ -913,8 +1033,8 @@ export class Popup extends EventDispatcher {
/**
* Computes the bounding rectangle for a set of rectangles.
- * @param {Rect[]} sourceRects An array of rectangles.
- * @returns {Rect} The bounding rectangle for all of the source rectangles.
+ * @param {import('popup').Rect[]} sourceRects An array of rectangles.
+ * @returns {import('popup').Rect} The bounding rectangle for all of the source rectangles.
*/
_getBoundingSourceRect(sourceRects) {
switch (sourceRects.length) {
@@ -934,8 +1054,8 @@ export class Popup extends EventDispatcher {
/**
* Checks whether or not a rectangle is overlapping any other rectangles.
- * @param {SizeRect} sizeRect The rectangles to check for overlaps.
- * @param {Rect[]} sourceRects The list of rectangles to compare against.
+ * @param {import('popup').SizeRect} sizeRect The rectangles to check for overlaps.
+ * @param {import('popup').Rect[]} sourceRects The list of rectangles to compare against.
* @param {number} ignoreIndex The index of an item in `sourceRects` to ignore.
* @returns {boolean} `true` if `sizeRect` overlaps any one of `sourceRects`, excluding `sourceRects[ignoreIndex]`; `false` otherwise.
*/
@@ -968,8 +1088,8 @@ export class Popup extends EventDispatcher {
/**
* Converts the coordinate space of source rectangles.
- * @param {Rect[]} sourceRects The list of rectangles to convert.
- * @returns {Rect[]} Either an updated list of rectangles, or `sourceRects` if no change is required.
+ * @param {import('popup').Rect[]} sourceRects The list of rectangles to convert.
+ * @returns {import('popup').Rect[]} Either an updated list of rectangles, or `sourceRects` if no change is required.
*/
_convertSourceRectsCoordinateSpace(sourceRects) {
let scale = DocumentUtil.computeZoomScale(this._container);
@@ -984,9 +1104,9 @@ export class Popup extends EventDispatcher {
/**
* Creates a scaled rectangle.
- * @param {Rect} rect The rectangle to scale.
+ * @param {import('popup').Rect} rect The rectangle to scale.
* @param {number} scale The scale factor.
- * @returns {Rect} A new rectangle which has been scaled.
+ * @returns {import('popup').Rect} A new rectangle which has been scaled.
*/
_createScaledRect(rect, scale) {
return {
@@ -997,3 +1117,18 @@ export class Popup extends EventDispatcher {
};
}
}
+
+class PopupError extends ExtensionError {
+ /**
+ * @param {string} message
+ * @param {Popup} source
+ */
+ constructor(message, source) {
+ super(message);
+ /** @type {Popup} */
+ this._source = source;
+ }
+
+ /** @type {Popup} */
+ get source() { return this._source; }
+}
diff --git a/ext/js/app/theme-controller.js b/ext/js/app/theme-controller.js
index f403a534..8b88c834 100644
--- a/ext/js/app/theme-controller.js
+++ b/ext/js/app/theme-controller.js
@@ -22,13 +22,18 @@
export class ThemeController {
/**
* Creates a new instance of the class.
- * @param {?Element} element A DOM element which theme properties are applied to.
+ * @param {?HTMLElement} element A DOM element which theme properties are applied to.
*/
constructor(element) {
+ /** @type {?HTMLElement} */
this._element = element;
- this._theme = 'default';
- this._outerTheme = 'default';
+ /** @type {'light'|'dark'|'browser'} */
+ this._theme = 'light';
+ /** @type {'light'|'dark'|'browser'|'site'} */
+ this._outerTheme = 'light';
+ /** @type {?('dark'|'light')} */
this._siteTheme = null;
+ /** @type {'dark'|'light'} */
this._browserTheme = 'light';
}
@@ -42,7 +47,7 @@ export class ThemeController {
/**
* Sets the DOM element which theme properties are applied to.
- * @param {?Element} value The DOM element to assign.
+ * @param {?HTMLElement} value The DOM element to assign.
*/
set element(value) {
this._element = value;
@@ -50,7 +55,7 @@ export class ThemeController {
/**
* Gets the main theme for the content.
- * @type {string}
+ * @type {'light'|'dark'|'browser'}
*/
get theme() {
return this._theme;
@@ -58,7 +63,7 @@ export class ThemeController {
/**
* Sets the main theme for the content.
- * @param {string} value The theme value to assign.
+ * @param {'light'|'dark'|'browser'} value The theme value to assign.
*/
set theme(value) {
this._theme = value;
@@ -66,7 +71,7 @@ export class ThemeController {
/**
* Gets the outer theme for the content.
- * @type {string}
+ * @type {'light'|'dark'|'browser'|'site'}
*/
get outerTheme() {
return this._outerTheme;
@@ -74,7 +79,7 @@ export class ThemeController {
/**
* Sets the outer theme for the content.
- * @param {string} value The outer theme value to assign.
+ * @param {'light'|'dark'|'browser'|'site'} value The outer theme value to assign.
*/
set outerTheme(value) {
this._outerTheme = value;
@@ -83,7 +88,7 @@ export class ThemeController {
/**
* Gets the override value for the site theme.
* If this value is `null`, the computed value will be used.
- * @type {?string}
+ * @type {?('dark'|'light')}
*/
get siteTheme() {
return this._siteTheme;
@@ -92,7 +97,7 @@ export class ThemeController {
/**
* Sets the override value for the site theme.
* If this value is `null`, the computed value will be used.
- * @param {?string} value The site theme value to assign.
+ * @param {?('dark'|'light')} value The site theme value to assign.
*/
set siteTheme(value) {
this._siteTheme = value;
@@ -101,7 +106,7 @@ export class ThemeController {
/**
* Gets the browser's preferred color theme.
* The value can be either 'light' or 'dark'.
- * @type {?string}
+ * @type {'dark'|'light'}
*/
get browserTheme() {
return this._browserTheme;
@@ -152,7 +157,6 @@ export class ThemeController {
/**
* Event handler for when the preferred browser theme changes.
* @param {MediaQueryList|MediaQueryListEvent} detail The object containing event details.
- * @param {boolean} detail.matches The object containing event details.
*/
_onPrefersColorSchemeDarkChange({matches}) {
this._browserTheme = (matches ? 'dark' : 'light');