diff options
Diffstat (limited to 'ext/js/app')
-rw-r--r-- | ext/js/app/content-script-main.js | 2 | ||||
-rw-r--r-- | ext/js/app/frontend.js | 317 | ||||
-rw-r--r-- | ext/js/app/popup-factory.js | 182 | ||||
-rw-r--r-- | ext/js/app/popup-proxy.js | 108 | ||||
-rw-r--r-- | ext/js/app/popup-window.js | 72 | ||||
-rw-r--r-- | ext/js/app/popup.js | 334 | ||||
-rw-r--r-- | ext/js/app/theme-controller.js | 28 |
7 files changed, 754 insertions, 289 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..e1f8d8c9 100644 --- a/ext/js/app/frontend.js +++ b/ext/js/app/frontend.js @@ -17,15 +17,12 @@ */ 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'; -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 +30,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 +46,43 @@ export class Frontend { childrenSupported=true, hotkeyHandler }) { + /** @type {import('frontend').PageType} */ this._pageType = pageType; + /** @type {import('./popup-factory.js').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 {import('../input/hotkey-handler.js').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 +91,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 {?import('core').Timeout} */ 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 +137,7 @@ export class Frontend { /** * Gets the popup instance. - * @type {Popup} + * @type {?import('popup').PopupAny} */ get popup() { return this._popup; @@ -148,8 +160,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 +184,7 @@ export class Frontend { this._prepareSiteSpecific(); this._updateContentScale(); - this._signalFrontendReady(); + this._signalFrontendReady(null); } /** @@ -186,7 +198,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 +206,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 +228,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 +236,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 +310,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 +322,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 +396,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 +443,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 +463,9 @@ export class Frontend { } } + /** + * @returns {void} + */ _stopClearSelectionDelayed() { if (this._clearSelectionTimer !== null) { clearTimeout(this._clearSelectionTimer); @@ -386,6 +473,9 @@ export class Frontend { } } + /** + * @returns {Promise<void>} + */ async _updateOptionsInternal() { const optionsContext = await this._getOptionsContext(); const options = await yomitan.api.optionsGet(optionsContext); @@ -426,12 +516,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 +560,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 +584,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 +600,9 @@ export class Frontend { }); } + /** + * @returns {Promise<import('popup').PopupAny>} + */ async _getProxyPopup() { return await this._popupFactory.getOrCreatePopup({ frameId: this._parentFrameId, @@ -511,6 +612,9 @@ export class Frontend { }); } + /** + * @returns {Promise<?import('popup').PopupAny>} + */ async _getIframeProxyPopup() { const targetFrameId = 0; // Root frameId try { @@ -520,7 +624,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 +642,9 @@ export class Frontend { return popup; } + /** + * @returns {Promise<import('popup').PopupAny>} + */ async _getPopupWindow() { return await this._popupFactory.getOrCreatePopup({ depth: this._depth, @@ -545,6 +653,9 @@ export class Frontend { }); } + /** + * @returns {Element[]} + */ _ignoreElements() { if (this._popup !== null) { const container = this._popup.container; @@ -555,6 +666,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 +682,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 +728,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 +768,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 +783,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 +806,9 @@ export class Frontend { this._updatePopupPosition(); } + /** + * @returns {Promise<void>} + */ async _updatePopupPosition() { const textSource = this._textScanner.getCurrentTextSource(); if ( @@ -674,7 +820,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 +833,14 @@ export class Frontend { } } + /** + * @param {number} frameId + * @param {?number} timeout + * @returns {Promise<void>} + */ async _waitForFrontendReady(frameId, timeout) { return new Promise((resolve, reject) => { + /** @type {?import('core').Timeout} */ let timeoutId = null; const cleanup = () => { @@ -694,10 +850,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 +877,10 @@ export class Frontend { }); } + /** + * @param {import('settings').PreventMiddleMouseOptions} preventMiddleMouseOptions + * @returns {boolean} + */ _getPreventMiddleMouseValueForPageType(preventMiddleMouseOptions) { switch (this._pageType) { case 'web': return preventMiddleMouseOptions.onWebPages; @@ -729,6 +890,9 @@ export class Frontend { } } + /** + * @returns {Promise<import('settings').OptionsContext>} + */ async _getOptionsContext() { let optionsContext = this._optionsContextOverride; if (optionsContext === null) { @@ -737,10 +901,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 +926,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 +938,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 +954,9 @@ export class Frontend { return null; } + /** + * @returns {void} + */ _prepareSiteSpecific() { switch (location.hostname.toLowerCase()) { case 'docs.google.com': @@ -786,11 +965,21 @@ export class Frontend { } } + /** + * @returns {Promise<void>} + */ async _prepareGoogleDocs() { if (typeof GoogleDocsUtil !== 'undefined') { return; } await yomitan.api.loadExtensionScripts([ '/js/accessibility/google-docs-util.js' ]); + this._prepareGoogleDocs2(); + } + + /** + * @returns {Promise<void>} + */ + async _prepareGoogleDocs2() { if (typeof GoogleDocsUtil === 'undefined') { return; } DocumentUtil.registerGetRangeFromPointHandler(GoogleDocsUtil.getRangeFromPoint.bind(GoogleDocsUtil)); } 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..924175e2 100644 --- a/ext/js/app/popup-proxy.js +++ b/ext/js/app/popup-proxy.js @@ -16,23 +16,18 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {FrameOffsetForwarder} from '../comm/frame-offset-forwarder.js'; import {EventDispatcher, log} from '../core.js'; import {yomitan} from '../yomitan.js'; -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 +36,24 @@ export class PopupProxy extends EventDispatcher { frameOffsetForwarder }) { super(); + /** @type {string} */ this._id = id; + /** @type {number} */ this._depth = depth; + /** @type {number} */ this._frameId = frameId; + /** @type {?import('../comm/frame-offset-forwarder.js').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 +68,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 {?import('./popup.js').Popup} */ get parent() { return null; @@ -72,7 +76,7 @@ export class PopupProxy extends EventDispatcher { /** * Attempts to set the parent popup. - * @param {Popup} _value The parent to assign. + * @param {import('./popup.js').Popup} _value The parent to assign. * @throws {Error} Throws an error, since this class doesn't support a direct parent. */ set parent(_value) { @@ -82,7 +86,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 {?import('./popup.js').Popup} */ get child() { return null; @@ -90,7 +94,7 @@ export class PopupProxy extends EventDispatcher { /** * Attempts to set the child popup. - * @param {Popup} _child The child to assign. + * @param {import('./popup.js').Popup} _child The child to assign. * @throws {Error} Throws an error, since this class doesn't support children. */ set child(_child) { @@ -99,7 +103,7 @@ export class PopupProxy extends EventDispatcher { /** * The depth of the popup. - * @type {numer} + * @type {number} */ get depth() { return this._depth; @@ -108,7 +112,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 +120,7 @@ export class PopupProxy extends EventDispatcher { /** * Gets the DOM node that contains the frame. - * @type {Element} + * @type {?Element} */ get container() { return null; @@ -132,11 +136,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 +148,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 +164,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 +173,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 +197,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 +212,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 +220,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 +237,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 +253,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 +264,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 +279,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 +292,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 +326,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 +348,11 @@ export class PopupProxy extends EventDispatcher { } } + /** + * @param {number} now + */ async _updateFrameOffsetInner(now) { - this._frameOffsetPromise = this._frameOffsetForwarder.getOffset(); + this._frameOffsetPromise = /** @type {import('../comm/frame-offset-forwarder.js').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..9a0f8011 100644 --- a/ext/js/app/popup-window.js +++ b/ext/js/app/popup-window.js @@ -18,18 +18,15 @@ import {EventDispatcher} from '../core.js'; import {yomitan} from '../yomitan.js'; -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 +34,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 +52,9 @@ export class PopupWindow extends EventDispatcher { return this._id; } + /** + * @type {?import('./popup.js').Popup} + */ get parent() { return null; } @@ -58,7 +62,7 @@ export class PopupWindow extends EventDispatcher { /** * The parent of the popup, which is always `null` for `PopupWindow` instances, * since any potential parent popups are in a different frame. - * @param {Popup} _value The parent to assign. + * @param {import('./popup.js').Popup} _value The parent to assign. * @throws {Error} Throws an error, since this class doesn't support children. */ set parent(_value) { @@ -68,7 +72,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 {?import('./popup.js').Popup} */ get child() { return null; @@ -76,7 +80,7 @@ export class PopupWindow extends EventDispatcher { /** * Attempts to set the child popup. - * @param {Popup} _value The child to assign. + * @param {import('./popup.js').Popup} _value The child to assign. * @throws Throws an error, since this class doesn't support children. */ set child(_value) { @@ -85,7 +89,7 @@ export class PopupWindow extends EventDispatcher { /** * The depth of the popup. - * @type {numer} + * @type {number} */ get depth() { return this._depth; @@ -94,7 +98,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 +106,7 @@ export class PopupWindow extends EventDispatcher { /** * Gets the DOM node that contains the frame. - * @type {Element} + * @type {?Element} */ get container() { return null; @@ -118,11 +122,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 +149,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 +158,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 +177,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 +191,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 +239,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 +248,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 +266,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 +292,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..7419785b 100644 --- a/ext/js/app/popup.js +++ b/ext/js/app/popup.js @@ -18,6 +18,7 @@ import {FrameClient} from '../comm/frame-client.js'; import {DynamicProperty, EventDispatcher, EventListenerCollection, deepEqual} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {DocumentUtil} from '../dom/document-util.js'; import {dynamicLoader} from '../script/dynamic-loader.js'; import {yomitan} from '../yomitan.js'; @@ -25,53 +26,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 +40,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 +130,7 @@ export class Popup extends EventDispatcher { /** * The parent of the popup. - * @type {Popup} + * @type {?Popup} */ get parent() { return this._parent; @@ -151,7 +146,7 @@ export class Popup extends EventDispatcher { /** * The child of the popup. - * @type {Popup} + * @type {?Popup} */ get child() { return this._child; @@ -167,7 +162,7 @@ export class Popup extends EventDispatcher { /** * The depth of the popup. - * @type {numer} + * @type {number} */ get depth() { return this._depth; @@ -215,11 +210,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 +249,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 +258,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 +272,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 +284,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 +307,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 +360,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 +377,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 +397,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 +430,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 +456,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 +465,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 +479,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 +514,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 +544,9 @@ export class Popup extends EventDispatcher { await this._injectStyles(); } + /** + * @returns {Promise<void>} + */ async _injectStyles() { try { await this._injectPopupOuterStylesheet(); @@ -528,7 +561,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 +577,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 +594,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 +604,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 +635,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 +662,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 +681,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 +726,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 +745,8 @@ export class Popup extends EventDispatcher { if ( fullscreenElement === null || fullscreenElement.shadowRoot || - fullscreenElement.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + // @ts-expect-error - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions + fullscreenElement.openOrClosedShadowRoot ) { return defaultParent; } @@ -675,10 +762,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 +792,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 +808,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 +845,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 +874,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 +893,10 @@ export class Popup extends EventDispatcher { } } + /** + * @param {import('document-util').NormalizedWritingMode} writingMode + * @returns {boolean} + */ _isWritingModeLeftToRight(writingMode) { switch (writingMode) { case 'vertical-lr': @@ -809,6 +907,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 +931,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 +963,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 +998,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 +1021,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 +1034,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 +1055,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 +1089,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 +1105,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 +1118,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'); |