diff options
Diffstat (limited to 'ext/js')
144 files changed, 15553 insertions, 4421 deletions
diff --git a/ext/js/accessibility/accessibility-controller.js b/ext/js/accessibility/accessibility-controller.js index e9a24880..8250b369 100644 --- a/ext/js/accessibility/accessibility-controller.js +++ b/ext/js/accessibility/accessibility-controller.js @@ -16,7 +16,6 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {ScriptManager} from '../background/script-manager.js'; import {log} from '../core.js'; /** @@ -25,18 +24,22 @@ import {log} from '../core.js'; export class AccessibilityController { /** * Creates a new instance. - * @param {ScriptManager} scriptManager An instance of the `ScriptManager` class. + * @param {import('../background/script-manager.js').ScriptManager} scriptManager An instance of the `ScriptManager` class. */ constructor(scriptManager) { + /** @type {import('../background/script-manager.js').ScriptManager} */ this._scriptManager = scriptManager; + /** @type {?import('core').TokenObject} */ this._updateGoogleDocsAccessibilityToken = null; + /** @type {?Promise<void>} */ this._updateGoogleDocsAccessibilityPromise = null; + /** @type {boolean} */ this._forceGoogleDocsHtmlRenderingAny = false; } /** * Updates the accessibility handlers. - * @param {object} fullOptions The full options object from the `Backend` instance. + * @param {import('settings').Options} fullOptions The full options object from the `Backend` instance. * The value is treated as read-only and is not modified. */ async update(fullOptions) { @@ -53,8 +56,12 @@ export class AccessibilityController { // Private + /** + * @param {boolean} forceGoogleDocsHtmlRenderingAny + */ async _updateGoogleDocsAccessibility(forceGoogleDocsHtmlRenderingAny) { // Reentrant token + /** @type {?import('core').TokenObject} */ const token = {}; this._updateGoogleDocsAccessibilityToken = token; @@ -72,6 +79,9 @@ export class AccessibilityController { this._updateGoogleDocsAccessibilityPromise = null; } + /** + * @param {boolean} forceGoogleDocsHtmlRenderingAny + */ async _updateGoogleDocsAccessibilityInner(forceGoogleDocsHtmlRenderingAny) { if (this._forceGoogleDocsHtmlRenderingAny === forceGoogleDocsHtmlRenderingAny) { return; } @@ -81,6 +91,7 @@ export class AccessibilityController { try { if (forceGoogleDocsHtmlRenderingAny) { if (await this._scriptManager.isContentScriptRegistered(id)) { return; } + /** @type {import('script-manager').RegistrationDetails} */ const details = { allFrames: true, matchAboutBlank: true, diff --git a/ext/js/accessibility/google-docs-util.js b/ext/js/accessibility/google-docs-util.js index e170f334..4321f082 100644 --- a/ext/js/accessibility/google-docs-util.js +++ b/ext/js/accessibility/google-docs-util.js @@ -17,7 +17,6 @@ */ import {DocumentUtil} from '../dom/document-util.js'; -import {TextSourceElement} from '../dom/text-source-element.js'; import {TextSourceRange} from '../dom/text-source-range.js'; /** @@ -29,8 +28,8 @@ export class GoogleDocsUtil { * Coordinates are provided in [client space](https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems). * @param {number} x The x coordinate to search at. * @param {number} y The y coordinate to search at. - * @param {GetRangeFromPointOptions} options Options to configure how element detection is performed. - * @returns {?TextSourceRange|TextSourceElement} A range for the hovered text or element, or `null` if no applicable content was found. + * @param {import('document-util').GetRangeFromPointOptions} options Options to configure how element detection is performed. + * @returns {?TextSourceRange} A range for the hovered text or element, or `null` if no applicable content was found. */ static getRangeFromPoint(x, y, {normalizeCssZoom}) { const styleNode = this._getStyleNode(); @@ -46,10 +45,13 @@ export class GoogleDocsUtil { return null; } + /** + * @returns {HTMLStyleElement} + */ static _getStyleNode() { // This <style> node is necessary to force the SVG <rect> elements to have a fill, // which allows them to be included in document.elementsFromPoint's return value. - if (this._styleNode === null) { + if (typeof this._styleNode === 'undefined') { const style = document.createElement('style'); style.textContent = [ '.kix-canvas-tile-content{pointer-events:none!important;}', @@ -64,21 +66,34 @@ export class GoogleDocsUtil { return this._styleNode; } + /** + * @param {Element} element + * @param {string} text + * @param {number} x + * @param {number} y + * @param {boolean} normalizeCssZoom + * @returns {TextSourceRange} + */ static _createRange(element, text, x, y, normalizeCssZoom) { // Create imposter const content = document.createTextNode(text); const svgText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); const transform = element.getAttribute('transform') || ''; const font = element.getAttribute('data-font-css') || ''; - svgText.setAttribute('x', element.getAttribute('x')); - svgText.setAttribute('y', element.getAttribute('y')); + const elementX = element.getAttribute('x'); + const elementY = element.getAttribute('y'); + if (typeof elementX === 'string') { svgText.setAttribute('x', elementX); } + if (typeof elementY === 'string') { svgText.setAttribute('y', elementY); } svgText.appendChild(content); const textStyle = svgText.style; this._setImportantStyle(textStyle, 'all', 'initial'); this._setImportantStyle(textStyle, 'transform', transform); this._setImportantStyle(textStyle, 'font', font); this._setImportantStyle(textStyle, 'text-anchor', 'start'); - element.parentNode.appendChild(svgText); + const {parentNode} = element; + if (parentNode !== null) { + parentNode.appendChild(svgText); + } // Adjust offset const elementRect = element.getBoundingClientRect(); @@ -93,6 +108,13 @@ export class GoogleDocsUtil { return TextSourceRange.createFromImposter(range, svgText, element); } + /** + * @param {Text} textNode + * @param {number} x + * @param {number} y + * @param {boolean} normalizeCssZoom + * @returns {Range} + */ static _getRangeWithPoint(textNode, x, y, normalizeCssZoom) { if (normalizeCssZoom) { const scale = DocumentUtil.computeZoomScale(textNode); @@ -101,7 +123,7 @@ export class GoogleDocsUtil { } const range = document.createRange(); let start = 0; - let end = textNode.nodeValue.length; + let end = /** @type {string} */ (textNode.nodeValue).length; while (end - start > 1) { const mid = Math.floor((start + end) / 2); range.setStart(textNode, mid); @@ -117,9 +139,16 @@ export class GoogleDocsUtil { return range; } + /** + * @param {CSSStyleDeclaration} style + * @param {string} propertyName + * @param {string} value + */ static _setImportantStyle(style, propertyName, value) { style.setProperty(propertyName, value, 'important'); } } + +/** @type {HTMLStyleElement|undefined} */ // eslint-disable-next-line no-underscore-dangle -GoogleDocsUtil._styleNode = null; +GoogleDocsUtil._styleNode = void 0; diff --git a/ext/js/accessibility/google-docs.js b/ext/js/accessibility/google-docs.js index f9290078..27841b6d 100644 --- a/ext/js/accessibility/google-docs.js +++ b/ext/js/accessibility/google-docs.js @@ -18,9 +18,17 @@ (async () => { // Reentrant check + // @ts-expect-error - Checking a property to the global object if (self.googleDocsAccessibilitySetup) { return; } + // @ts-expect-error - Adding a property to the global object self.googleDocsAccessibilitySetup = true; + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<TReturn>} + */ const invokeApi = (action, params) => { return new Promise((resolve, reject) => { chrome.runtime.sendMessage({action, params}, (response) => { @@ -37,6 +45,7 @@ }; const optionsContext = {depth: 0, url: location.href}; + /** @type {import('api').OptionsGetResult} */ let options; try { options = await invokeApi('optionsGet', {optionsContext}); @@ -48,9 +57,8 @@ // The extension ID below is on an allow-list that is used on the Google Docs webpage. /* eslint-disable */ - const inject = () => { - window._docs_annotate_canvas_by_ext = 'ogmnaimimemjmbakcfefmnahgdfhfami'; - }; + // @ts-expect-error : Adding a property to the global object + const inject = () => { window._docs_annotate_canvas_by_ext = 'ogmnaimimemjmbakcfefmnahgdfhfami'; }; /* eslint-enable */ let parent = document.head; 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'); diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js index bf4841f8..3eefed53 100644 --- a/ext/js/background/backend.js +++ b/ext/js/background/backend.js @@ -22,7 +22,8 @@ import {AnkiConnect} from '../comm/anki-connect.js'; import {ClipboardMonitor} from '../comm/clipboard-monitor.js'; import {ClipboardReader} from '../comm/clipboard-reader.js'; import {Mecab} from '../comm/mecab.js'; -import {clone, deferPromise, deserializeError, generateId, invokeMessageHandler, isObject, log, promiseTimeout, serializeError} from '../core.js'; +import {clone, deferPromise, generateId, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {AnkiUtil} from '../data/anki-util.js'; import {OptionsUtil} from '../data/options-util.js'; import {PermissionsUtil} from '../data/permissions-util.js'; @@ -35,7 +36,7 @@ import {Translator} from '../language/translator.js'; import {AudioDownloader} from '../media/audio-downloader.js'; import {MediaUtil} from '../media/media-util.js'; import {yomitan} from '../yomitan.js'; -import {OffscreenProxy, DictionaryDatabaseProxy, TranslatorProxy, ClipboardReaderProxy} from './offscreen-proxy.js'; +import {ClipboardReaderProxy, DictionaryDatabaseProxy, OffscreenProxy, TranslatorProxy} from './offscreen-proxy.js'; import {ProfileConditionsUtil} from './profile-conditions-util.js'; import {RequestBuilder} from './request-builder.js'; import {ScriptManager} from './script-manager.js'; @@ -49,17 +50,28 @@ export class Backend { * Creates a new instance. */ constructor() { + /** @type {JapaneseUtil} */ this._japaneseUtil = new JapaneseUtil(wanakana); + /** @type {Environment} */ this._environment = new Environment(); + /** + * + */ this._anki = new AnkiConnect(); + /** @type {Mecab} */ this._mecab = new Mecab(); if (!chrome.offscreen) { + /** @type {?OffscreenProxy} */ + this._offscreen = null; + /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ this._dictionaryDatabase = new DictionaryDatabase(); + /** @type {Translator|TranslatorProxy} */ this._translator = new Translator({ japaneseUtil: this._japaneseUtil, database: this._dictionaryDatabase }); + /** @type {ClipboardReader|ClipboardReaderProxy} */ this._clipboardReader = new ClipboardReader({ // eslint-disable-next-line no-undef document: (typeof document === 'object' && document !== null ? document : null), @@ -67,54 +79,83 @@ export class Backend { richContentPasteTargetSelector: '#clipboard-rich-content-paste-target' }); } else { + /** @type {?OffscreenProxy} */ this._offscreen = new OffscreenProxy(); + /** @type {DictionaryDatabase|DictionaryDatabaseProxy} */ this._dictionaryDatabase = new DictionaryDatabaseProxy(this._offscreen); + /** @type {Translator|TranslatorProxy} */ this._translator = new TranslatorProxy(this._offscreen); + /** @type {ClipboardReader|ClipboardReaderProxy} */ this._clipboardReader = new ClipboardReaderProxy(this._offscreen); } + /** @type {ClipboardMonitor} */ this._clipboardMonitor = new ClipboardMonitor({ japaneseUtil: this._japaneseUtil, clipboardReader: this._clipboardReader }); + /** @type {?import('settings').Options} */ this._options = null; + /** @type {import('../data/json-schema.js').JsonSchema[]} */ this._profileConditionsSchemaCache = []; + /** @type {ProfileConditionsUtil} */ this._profileConditionsUtil = new ProfileConditionsUtil(); + /** @type {?string} */ this._defaultAnkiFieldTemplates = null; + /** @type {RequestBuilder} */ this._requestBuilder = new RequestBuilder(); + /** @type {AudioDownloader} */ this._audioDownloader = new AudioDownloader({ japaneseUtil: this._japaneseUtil, requestBuilder: this._requestBuilder }); + /** @type {OptionsUtil} */ this._optionsUtil = new OptionsUtil(); + /** @type {ScriptManager} */ this._scriptManager = new ScriptManager(); + /** @type {AccessibilityController} */ this._accessibilityController = new AccessibilityController(this._scriptManager); + /** @type {?number} */ this._searchPopupTabId = null; + /** @type {?Promise<{tab: chrome.tabs.Tab, created: boolean}>} */ this._searchPopupTabCreatePromise = null; + /** @type {boolean} */ this._isPrepared = false; + /** @type {boolean} */ this._prepareError = false; + /** @type {?Promise<void>} */ this._preparePromise = null; + /** @type {import('core').DeferredPromiseDetails<void>} */ const {promise, resolve, reject} = deferPromise(); + /** @type {Promise<void>} */ this._prepareCompletePromise = promise; + /** @type {() => void} */ this._prepareCompleteResolve = resolve; + /** @type {(reason?: unknown) => void} */ this._prepareCompleteReject = reject; + /** @type {?string} */ this._defaultBrowserActionTitle = null; + /** @type {?import('core').Timeout} */ this._badgePrepareDelayTimer = null; + /** @type {?import('log').LogLevel} */ this._logErrorLevel = null; + /** @type {?chrome.permissions.Permissions} */ this._permissions = null; + /** @type {PermissionsUtil} */ this._permissionsUtil = new PermissionsUtil(); - this._messageHandlers = new Map([ + /** @type {import('backend').MessageHandlerMap} */ + this._messageHandlers = new Map(/** @type {import('backend').MessageHandlerMapInit} */ ([ ['requestBackendReadySignal', {async: false, contentScript: true, handler: this._onApiRequestBackendReadySignal.bind(this)}], ['optionsGet', {async: false, contentScript: true, handler: this._onApiOptionsGet.bind(this)}], ['optionsGetFull', {async: false, contentScript: true, handler: this._onApiOptionsGetFull.bind(this)}], ['kanjiFind', {async: true, contentScript: true, handler: this._onApiKanjiFind.bind(this)}], ['termsFind', {async: true, contentScript: true, handler: this._onApiTermsFind.bind(this)}], ['parseText', {async: true, contentScript: true, handler: this._onApiParseText.bind(this)}], - ['getAnkiConnectVersion', {async: true, contentScript: true, handler: this._onApGetAnkiConnectVersion.bind(this)}], + ['getAnkiConnectVersion', {async: true, contentScript: true, handler: this._onApiGetAnkiConnectVersion.bind(this)}], ['isAnkiConnected', {async: true, contentScript: true, handler: this._onApiIsAnkiConnected.bind(this)}], ['addAnkiNote', {async: true, contentScript: true, handler: this._onApiAddAnkiNote.bind(this)}], ['getAnkiNoteInfo', {async: true, contentScript: true, handler: this._onApiGetAnkiNoteInfo.bind(this)}], @@ -151,17 +192,20 @@ export class Backend { ['findAnkiNotes', {async: true, contentScript: true, handler: this._onApiFindAnkiNotes.bind(this)}], ['loadExtensionScripts', {async: true, contentScript: true, handler: this._onApiLoadExtensionScripts.bind(this)}], ['openCrossFramePort', {async: false, contentScript: true, handler: this._onApiOpenCrossFramePort.bind(this)}] - ]); - this._messageHandlersWithProgress = new Map([ - ]); - - this._commandHandlers = new Map([ + ])); + /** @type {import('backend').MessageHandlerWithProgressMap} */ + this._messageHandlersWithProgress = new Map(/** @type {import('backend').MessageHandlerWithProgressMapInit} */ ([ + // Empty + ])); + + /** @type {Map<string, (params?: import('core').SerializableObject) => void>} */ + this._commandHandlers = new Map(/** @type {[name: string, handler: (params?: import('core').SerializableObject) => void][]} */ ([ ['toggleTextScanning', this._onCommandToggleTextScanning.bind(this)], ['openInfoPage', this._onCommandOpenInfoPage.bind(this)], ['openSettingsPage', this._onCommandOpenSettingsPage.bind(this)], ['openSearchPage', this._onCommandOpenSearchPage.bind(this)], ['openPopupWindow', this._onCommandOpenPopupWindow.bind(this)] - ]); + ])); } /** @@ -172,9 +216,9 @@ export class Backend { if (this._preparePromise === null) { const promise = this._prepareInternal(); promise.then( - (value) => { + () => { this._isPrepared = true; - this._prepareCompleteResolve(value); + this._prepareCompleteResolve(); }, (error) => { this._prepareError = true; @@ -189,6 +233,9 @@ export class Backend { // Private + /** + * @returns {void} + */ _prepareInternalSync() { if (isObject(chrome.commands) && isObject(chrome.commands.onCommand)) { const onCommand = this._onWebExtensionEventWrapper(this._onCommand.bind(this)); @@ -212,6 +259,9 @@ export class Backend { chrome.runtime.onInstalled.addListener(this._onInstalled.bind(this)); } + /** + * @returns {Promise<void>} + */ async _prepareInternal() { try { this._prepareInternalSync(); @@ -224,11 +274,11 @@ export class Backend { }, 1000); this._updateBadge(); - yomitan.on('log', this._onLog.bind(this)); + log.on('log', this._onLog.bind(this)); await this._requestBuilder.prepare(); await this._environment.prepare(); - if (chrome.offscreen) { + if (this._offscreen !== null) { await this._offscreen.prepare(); } this._clipboardReader.browser = this._environment.getInfo().browser; @@ -239,16 +289,16 @@ export class Backend { log.error(e); } - const deinflectionReasons = await this._fetchAsset('/data/deinflect.json', true); + const deinflectionReasons = /** @type {import('deinflector').ReasonsRaw} */ (await this._fetchJson('/data/deinflect.json')); this._translator.prepare(deinflectionReasons); await this._optionsUtil.prepare(); - this._defaultAnkiFieldTemplates = (await this._fetchAsset('/data/templates/default-anki-field-templates.handlebars')).trim(); + this._defaultAnkiFieldTemplates = (await this._fetchText('/data/templates/default-anki-field-templates.handlebars')).trim(); this._options = await this._optionsUtil.load(); this._applyOptions('background'); - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); if (options.general.showGuide) { this._openWelcomeGuidePageOnce(); } @@ -270,20 +320,30 @@ export class Backend { // Event handlers + /** + * @param {{text: string}} params + */ async _onClipboardTextChange({text}) { - const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}); + const {clipboard: {maximumSearchLength}} = this._getProfileOptions({current: true}, false); if (text.length > maximumSearchLength) { text = text.substring(0, maximumSearchLength); } try { const {tab, created} = await this._getOrCreateSearchPopup(); + const {id} = tab; + if (typeof id !== 'number') { + throw new Error('Tab does not have an id'); + } await this._focusTab(tab); - await this._updateSearchQuery(tab.id, text, !created); + await this._updateSearchQuery(id, text, !created); } catch (e) { // NOP } } + /** + * @param {{level: import('log').LogLevel}} params + */ _onLog({level}) { const levelValue = this._getErrorLevelValue(level); if (levelValue <= this._getErrorLevelValue(this._logErrorLevel)) { return; } @@ -294,8 +354,13 @@ export class Backend { // WebExtension event handlers (with prepared checks) + /** + * @template {(...args: import('core').SafeAny[]) => void} T + * @param {T} handler + * @returns {T} + */ _onWebExtensionEventWrapper(handler) { - return (...args) => { + return /** @type {T} */ ((...args) => { if (this._isPrepared) { handler(...args); return; @@ -305,9 +370,10 @@ export class Backend { () => { handler(...args); }, () => {} // NOP ); - }; + }); } + /** @type {import('extension').ChromeRuntimeOnMessageCallback} */ _onMessageWrapper(message, sender, sendResponse) { if (this._isPrepared) { return this._onMessage(message, sender, sendResponse); @@ -322,10 +388,19 @@ export class Backend { // WebExtension event handlers + /** + * @param {string} command + */ _onCommand(command) { - this._runCommand(command); + this._runCommand(command, void 0); } + /** + * @param {{action: string, params?: import('core').SerializableObject}} message + * @param {chrome.runtime.MessageSender} sender + * @param {(response?: unknown) => void} callback + * @returns {boolean} + */ _onMessage({action, params}, sender, callback) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } @@ -334,7 +409,7 @@ export class Backend { try { this._validatePrivilegedMessageSender(sender); } catch (error) { - callback({error: serializeError(error)}); + callback({error: ExtensionError.serialize(error)}); return false; } } @@ -342,14 +417,23 @@ export class Backend { return invokeMessageHandler(messageHandler, params, callback, sender); } + /** + * @param {chrome.tabs.ZoomChangeInfo} event + */ _onZoomChange({tabId, oldZoomFactor, newZoomFactor}) { - this._sendMessageTabIgnoreResponse(tabId, {action: 'Yomitan.zoomChanged', params: {oldZoomFactor, newZoomFactor}}); + this._sendMessageTabIgnoreResponse(tabId, {action: 'Yomitan.zoomChanged', params: {oldZoomFactor, newZoomFactor}}, {}); } + /** + * @returns {void} + */ _onPermissionsChanged() { this._checkPermissions(); } + /** + * @param {chrome.runtime.InstalledDetails} event + */ _onInstalled({reason}) { if (reason !== 'install') { return; } this._requestPersistentStorage(); @@ -357,6 +441,7 @@ export class Backend { // Message handlers + /** @type {import('api').Handler<import('api').RequestBackendReadySignalDetails, import('api').RequestBackendReadySignalResult, true>} */ _onApiRequestBackendReadySignal(_params, sender) { // tab ID isn't set in background (e.g. browser_action) const data = {action: 'Yomitan.backendReady', params: {}}; @@ -364,21 +449,27 @@ export class Backend { this._sendMessageIgnoreResponse(data); return false; } else { - this._sendMessageTabIgnoreResponse(sender.tab.id, data); + const {id} = sender.tab; + if (typeof id === 'number') { + this._sendMessageTabIgnoreResponse(id, data, {}); + } return true; } } + /** @type {import('api').Handler<import('api').OptionsGetDetails, import('api').OptionsGetResult>} */ _onApiOptionsGet({optionsContext}) { - return this._getProfileOptions(optionsContext); + return this._getProfileOptions(optionsContext, false); } + /** @type {import('api').Handler<import('api').OptionsGetFullDetails, import('api').OptionsGetFullResult>} */ _onApiOptionsGetFull() { - return this._getOptionsFull(); + return this._getOptionsFull(false); } + /** @type {import('api').Handler<import('api').KanjiFindDetails, import('api').KanjiFindResult>} */ async _onApiKanjiFind({text, optionsContext}) { - const options = this._getProfileOptions(optionsContext); + const options = this._getProfileOptions(optionsContext, false); const {general: {maxResults}} = options; const findKanjiOptions = this._getTranslatorFindKanjiOptions(options); const dictionaryEntries = await this._translator.findKanji(text, findKanjiOptions); @@ -386,8 +477,9 @@ export class Backend { return dictionaryEntries; } + /** @type {import('api').Handler<import('api').TermsFindDetails, import('api').TermsFindResult>} */ async _onApiTermsFind({text, details, optionsContext}) { - const options = this._getProfileOptions(optionsContext); + const options = this._getProfileOptions(optionsContext, false); const {general: {resultOutputMode: mode, maxResults}} = options; const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); const {dictionaryEntries, originalTextLength} = await this._translator.findTerms(mode, text, findTermsOptions); @@ -395,12 +487,14 @@ export class Backend { return {dictionaryEntries, originalTextLength}; } + /** @type {import('api').Handler<import('api').ParseTextDetails, import('api').ParseTextResult>} */ async _onApiParseText({text, optionsContext, scanLength, useInternalParser, useMecabParser}) { const [internalResults, mecabResults] = await Promise.all([ (useInternalParser ? this._textParseScanning(text, scanLength, optionsContext) : null), (useMecabParser ? this._textParseMecab(text) : null) ]); + /** @type {import('api').ParseTextResultItem[]} */ const results = []; if (internalResults !== null) { @@ -426,20 +520,26 @@ export class Backend { return results; } - async _onApGetAnkiConnectVersion() { + /** @type {import('api').Handler<import('api').GetAnkiConnectVersionDetails, import('api').GetAnkiConnectVersionResult>} */ + async _onApiGetAnkiConnectVersion() { return await this._anki.getVersion(); } + /** @type {import('api').Handler<import('api').IsAnkiConnectedDetails, import('api').IsAnkiConnectedResult>} */ async _onApiIsAnkiConnected() { return await this._anki.isConnected(); } + /** @type {import('api').Handler<import('api').AddAnkiNoteDetails, import('api').AddAnkiNoteResult>} */ async _onApiAddAnkiNote({note}) { return await this._anki.addNote(note); } + /** @type {import('api').Handler<import('api').GetAnkiNoteInfoDetails, import('api').GetAnkiNoteInfoResult>} */ async _onApiGetAnkiNoteInfo({notes, fetchAdditionalInfo}) { + /** @type {import('anki').NoteInfoWrapper[]} */ const results = []; + /** @type {{note: import('anki').Note, info: import('anki').NoteInfoWrapper}[]} */ const cannotAdd = []; const canAddArray = await this._anki.canAddNotes(notes); @@ -472,6 +572,7 @@ export class Backend { return results; } + /** @type {import('api').Handler<import('api').InjectAnkiNoteMediaDetails, import('api').InjectAnkiNoteMediaResult>} */ async _onApiInjectAnkiNoteMedia({timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}) { return await this._injectAnkNoteMedia( this._anki, @@ -484,13 +585,14 @@ export class Backend { ); } + /** @type {import('api').Handler<import('api').NoteViewDetails, import('api').NoteViewResult>} */ async _onApiNoteView({noteId, mode, allowFallback}) { if (mode === 'edit') { try { await this._anki.guiEditNote(noteId); return 'edit'; } catch (e) { - if (!this._anki.isErrorUnsupportedAction(e)) { + if (!(e instanceof Error && this._anki.isErrorUnsupportedAction(e))) { throw e; } else if (!allowFallback) { throw new Error('Mode not supported'); @@ -502,6 +604,7 @@ export class Backend { return 'browse'; } + /** @type {import('api').Handler<import('api').SuspendAnkiCardsForNoteDetails, import('api').SuspendAnkiCardsForNoteResult>} */ async _onApiSuspendAnkiCardsForNote({noteId}) { const cardIds = await this._anki.findCardsForNote(noteId); const count = cardIds.length; @@ -512,76 +615,93 @@ export class Backend { return count; } + /** @type {import('api').Handler<import('api').CommandExecDetails, import('api').CommandExecResult>} */ _onApiCommandExec({command, params}) { return this._runCommand(command, params); } + /** @type {import('api').Handler<import('api').GetTermAudioInfoListDetails, import('api').GetTermAudioInfoListResult>} */ async _onApiGetTermAudioInfoList({source, term, reading}) { return await this._audioDownloader.getTermAudioInfoList(source, term, reading); } + /** @type {import('api').Handler<import('api').SendMessageToFrameDetails, import('api').SendMessageToFrameResult, true>} */ _onApiSendMessageToFrame({frameId: targetFrameId, action, params}, sender) { - if (!(sender && sender.tab)) { - return false; - } - - const tabId = sender.tab.id; + if (!sender) { return false; } + const {tab} = sender; + if (!tab) { return false; } + const {id} = tab; + if (typeof id !== 'number') { return false; } const frameId = sender.frameId; - this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}, {frameId: targetFrameId}); + /** @type {import('extension').ChromeRuntimeMessageWithFrameId} */ + const message = {action, params, frameId}; + this._sendMessageTabIgnoreResponse(id, message, {frameId: targetFrameId}); return true; } + /** @type {import('api').Handler<import('api').BroadcastTabDetails, import('api').BroadcastTabResult, true>} */ _onApiBroadcastTab({action, params}, sender) { - if (!(sender && sender.tab)) { - return false; - } - - const tabId = sender.tab.id; + if (!sender) { return false; } + const {tab} = sender; + if (!tab) { return false; } + const {id} = tab; + if (typeof id !== 'number') { return false; } const frameId = sender.frameId; - this._sendMessageTabIgnoreResponse(tabId, {action, params, frameId}); + /** @type {import('extension').ChromeRuntimeMessageWithFrameId} */ + const message = {action, params, frameId}; + this._sendMessageTabIgnoreResponse(id, message, {}); return true; } - _onApiFrameInformationGet(params, sender) { + /** @type {import('api').Handler<import('api').FrameInformationGetDetails, import('api').FrameInformationGetResult, true>} */ + _onApiFrameInformationGet(_params, sender) { const tab = sender.tab; const tabId = tab ? tab.id : void 0; const frameId = sender.frameId; return Promise.resolve({tabId, frameId}); } + /** @type {import('api').Handler<import('api').InjectStylesheetDetails, import('api').InjectStylesheetResult, true>} */ async _onApiInjectStylesheet({type, value}, sender) { const {frameId, tab} = sender; - if (!isObject(tab)) { throw new Error('Invalid tab'); } + if (typeof tab !== 'object' || tab === null || typeof tab.id !== 'number') { throw new Error('Invalid tab'); } return await this._scriptManager.injectStylesheet(type, value, tab.id, frameId, false); } + /** @type {import('api').Handler<import('api').GetStylesheetContentDetails, import('api').GetStylesheetContentResult>} */ async _onApiGetStylesheetContent({url}) { if (!url.startsWith('/') || url.startsWith('//') || !url.endsWith('.css')) { throw new Error('Invalid URL'); } - return await this._fetchAsset(url); + return await this._fetchText(url); } + /** @type {import('api').Handler<import('api').GetEnvironmentInfoDetails, import('api').GetEnvironmentInfoResult>} */ _onApiGetEnvironmentInfo() { return this._environment.getInfo(); } + /** @type {import('api').Handler<import('api').ClipboardGetDetails, import('api').ClipboardGetResult>} */ async _onApiClipboardGet() { return this._clipboardReader.getText(false); } + /** @type {import('api').Handler<import('api').GetDisplayTemplatesHtmlDetails, import('api').GetDisplayTemplatesHtmlResult>} */ async _onApiGetDisplayTemplatesHtml() { - return await this._fetchAsset('/display-templates.html'); + return await this._fetchText('/display-templates.html'); } - _onApiGetZoom(params, sender) { - if (!sender || !sender.tab) { - return Promise.reject(new Error('Invalid tab')); - } - + /** @type {import('api').Handler<import('api').GetZoomDetails, import('api').GetZoomResult, true>} */ + _onApiGetZoom(_params, sender) { return new Promise((resolve, reject) => { + if (!sender || !sender.tab) { + reject(new Error('Invalid tab')); + return; + } + const tabId = sender.tab.id; if (!( + typeof tabId === 'number' && chrome.tabs !== null && typeof chrome.tabs === 'object' && typeof chrome.tabs.getZoom === 'function' @@ -601,34 +721,41 @@ export class Backend { }); } + /** @type {import('api').Handler<import('api').GetDefaultAnkiFieldTemplatesDetails, import('api').GetDefaultAnkiFieldTemplatesResult>} */ _onApiGetDefaultAnkiFieldTemplates() { - return this._defaultAnkiFieldTemplates; + return /** @type {string} */ (this._defaultAnkiFieldTemplates); } + /** @type {import('api').Handler<import('api').GetDictionaryInfoDetails, import('api').GetDictionaryInfoResult>} */ async _onApiGetDictionaryInfo() { return await this._dictionaryDatabase.getDictionaryInfo(); } + /** @type {import('api').Handler<import('api').PurgeDatabaseDetails, import('api').PurgeDatabaseResult>} */ async _onApiPurgeDatabase() { await this._dictionaryDatabase.purge(); this._triggerDatabaseUpdated('dictionary', 'purge'); } + /** @type {import('api').Handler<import('api').GetMediaDetails, import('api').GetMediaResult>} */ async _onApiGetMedia({targets}) { return await this._getNormalizedDictionaryDatabaseMedia(targets); } + /** @type {import('api').Handler<import('api').LogDetails, import('api').LogResult>} */ _onApiLog({error, level, context}) { - log.log(deserializeError(error), level, context); + log.log(ExtensionError.deserialize(error), level, context); } + /** @type {import('api').Handler<import('api').LogIndicatorClearDetails, import('api').LogIndicatorClearResult>} */ _onApiLogIndicatorClear() { if (this._logErrorLevel === null) { return; } this._logErrorLevel = null; this._updateBadge(); } - _onApiCreateActionPort(params, sender) { + /** @type {import('api').Handler<import('api').CreateActionPortDetails, import('api').CreateActionPortResult, true>} */ + _onApiCreateActionPort(_params, sender) { if (!sender || !sender.tab) { throw new Error('Invalid sender'); } const tabId = sender.tab.id; if (typeof tabId !== 'number') { throw new Error('Sender has invalid tab ID'); } @@ -651,10 +778,12 @@ export class Backend { return details; } + /** @type {import('api').Handler<import('api').ModifySettingsDetails, import('api').ModifySettingsResult>} */ _onApiModifySettings({targets, source}) { return this._modifySettings(targets, source); } + /** @type {import('api').Handler<import('api').GetSettingsDetails, import('api').GetSettingsResult>} */ _onApiGetSettings({targets}) { const results = []; for (const target of targets) { @@ -662,39 +791,48 @@ export class Backend { const result = this._getSetting(target); results.push({result: clone(result)}); } catch (e) { - results.push({error: serializeError(e)}); + results.push({error: ExtensionError.serialize(e)}); } } return results; } + /** @type {import('api').Handler<import('api').SetAllSettingsDetails, import('api').SetAllSettingsResult>} */ async _onApiSetAllSettings({value, source}) { this._optionsUtil.validate(value); this._options = clone(value); await this._saveOptions(source); } - async _onApiGetOrCreateSearchPopup({focus=false, text=null}) { + /** @type {import('api').Handler<import('api').GetOrCreateSearchPopupDetails, import('api').GetOrCreateSearchPopupResult>} */ + async _onApiGetOrCreateSearchPopup({focus=false, text}) { const {tab, created} = await this._getOrCreateSearchPopup(); if (focus === true || (focus === 'ifCreated' && created)) { await this._focusTab(tab); } if (typeof text === 'string') { - await this._updateSearchQuery(tab.id, text, !created); + const {id} = tab; + if (typeof id === 'number') { + await this._updateSearchQuery(id, text, !created); + } } - return {tabId: tab.id, windowId: tab.windowId}; + const {id} = tab; + return {tabId: typeof id === 'number' ? id : null, windowId: tab.windowId}; } + /** @type {import('api').Handler<import('api').IsTabSearchPopupDetails, import('api').IsTabSearchPopupResult>} */ async _onApiIsTabSearchPopup({tabId}) { const baseUrl = chrome.runtime.getURL('/search.html'); - const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url.startsWith(baseUrl)) : null; + const tab = typeof tabId === 'number' ? await this._checkTabUrl(tabId, (url) => url !== null && url.startsWith(baseUrl)) : null; return (tab !== null); } + /** @type {import('api').Handler<import('api').TriggerDatabaseUpdatedDetails, import('api').TriggerDatabaseUpdatedResult>} */ _onApiTriggerDatabaseUpdated({type, cause}) { this._triggerDatabaseUpdated(type, cause); } + /** @type {import('api').Handler<import('api').TestMecabDetails, import('api').TestMecabResult>} */ async _onApiTestMecab() { if (!this._mecab.isEnabled()) { throw new Error('MeCab not enabled'); @@ -731,18 +869,22 @@ export class Backend { return true; } + /** @type {import('api').Handler<import('api').TextHasJapaneseCharactersDetails, import('api').TextHasJapaneseCharactersResult>} */ _onApiTextHasJapaneseCharacters({text}) { return this._japaneseUtil.isStringPartiallyJapanese(text); } + /** @type {import('api').Handler<import('api').GetTermFrequenciesDetails, import('api').GetTermFrequenciesResult>} */ async _onApiGetTermFrequencies({termReadingList, dictionaries}) { return await this._translator.getTermFrequencies(termReadingList, dictionaries); } + /** @type {import('api').Handler<import('api').FindAnkiNotesDetails, import('api').FindAnkiNotesResult>} */ async _onApiFindAnkiNotes({query}) { return await this._anki.findNotes(query); } + /** @type {import('api').Handler<import('api').LoadExtensionScriptsDetails, import('api').LoadExtensionScriptsResult, true>} */ async _onApiLoadExtensionScripts({files}, sender) { if (!sender || !sender.tab) { throw new Error('Invalid sender'); } const tabId = sender.tab.id; @@ -753,6 +895,7 @@ export class Backend { } } + /** @type {import('api').Handler<import('api').OpenCrossFramePortDetails, import('api').OpenCrossFramePortResult, true>} */ _onApiOpenCrossFramePort({targetTabId, targetFrameId}, sender) { const sourceTabId = (sender && sender.tab ? sender.tab.id : null); if (typeof sourceTabId !== 'number') { @@ -773,7 +916,9 @@ export class Backend { otherTabId: sourceTabId, otherFrameId: sourceFrameId }; + /** @type {?chrome.runtime.Port} */ let sourcePort = chrome.tabs.connect(sourceTabId, {frameId: sourceFrameId, name: JSON.stringify(sourceDetails)}); + /** @type {?chrome.runtime.Port} */ let targetPort = chrome.tabs.connect(targetTabId, {frameId: targetFrameId, name: JSON.stringify(targetDetails)}); const cleanup = () => { @@ -788,8 +933,12 @@ export class Backend { } }; - sourcePort.onMessage.addListener((message) => { targetPort.postMessage(message); }); - targetPort.onMessage.addListener((message) => { sourcePort.postMessage(message); }); + sourcePort.onMessage.addListener((message) => { + if (targetPort !== null) { targetPort.postMessage(message); } + }); + targetPort.onMessage.addListener((message) => { + if (sourcePort !== null) { sourcePort.postMessage(message); } + }); sourcePort.onDisconnect.addListener(cleanup); targetPort.onDisconnect.addListener(cleanup); @@ -798,18 +947,30 @@ export class Backend { // Command handlers + /** + * @param {undefined|{mode: 'existingOrNewTab'|'newTab', query?: string}} params + */ async _onCommandOpenSearchPage(params) { - const {mode='existingOrNewTab', query} = params || {}; + /** @type {'existingOrNewTab'|'newTab'} */ + let mode = 'existingOrNewTab'; + let query = ''; + if (typeof params === 'object' && params !== null) { + mode = this._normalizeOpenSettingsPageMode(params.mode, mode); + const paramsQuery = params.query; + if (typeof paramsQuery === 'string') { query = paramsQuery; } + } const baseUrl = chrome.runtime.getURL('/search.html'); + /** @type {{[key: string]: string}} */ const queryParams = {}; - if (query && query.length > 0) { queryParams.query = query; } + if (query.length > 0) { queryParams.query = query; } const queryString = new URLSearchParams(queryParams).toString(); let url = baseUrl; if (queryString.length > 0) { url += `?${queryString}`; } + /** @type {import('backend').FindTabsPredicate} */ const predicate = ({url: url2}) => { if (url2 === null || !url2.startsWith(baseUrl)) { return false; } const parsedUrl = new URL(url2); @@ -819,15 +980,19 @@ export class Backend { }; const openInTab = async () => { - const tabInfo = await this._findTabs(1000, false, predicate, false); + const tabInfo = /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, false)); if (tabInfo !== null) { const {tab} = tabInfo; - await this._focusTab(tab); - if (queryParams.query) { - await this._updateSearchQuery(tab.id, queryParams.query, true); + const {id} = tab; + if (typeof id === 'number') { + await this._focusTab(tab); + if (queryParams.query) { + await this._updateSearchQuery(id, queryParams.query, true); + } + return true; } - return true; } + return false; }; switch (mode) { @@ -845,46 +1010,73 @@ export class Backend { } } + /** + * @returns {Promise<void>} + */ async _onCommandOpenInfoPage() { await this._openInfoPage(); } + /** + * @param {undefined|{mode: 'existingOrNewTab'|'newTab'}} params + */ async _onCommandOpenSettingsPage(params) { - const {mode='existingOrNewTab'} = params || {}; + /** @type {'existingOrNewTab'|'newTab'} */ + let mode = 'existingOrNewTab'; + if (typeof params === 'object' && params !== null) { + mode = this._normalizeOpenSettingsPageMode(params.mode, mode); + } await this._openSettingsPage(mode); } + /** + * @returns {Promise<void>} + */ async _onCommandToggleTextScanning() { - const options = this._getProfileOptions({current: true}); - await this._modifySettings([{ + const options = this._getProfileOptions({current: true}, false); + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'general.enable', value: !options.general.enable, scope: 'profile', optionsContext: {current: true} - }], 'backend'); + }; + await this._modifySettings([modification], 'backend'); } + /** + * @returns {Promise<void>} + */ async _onCommandOpenPopupWindow() { await this._onApiGetOrCreateSearchPopup({focus: true}); } // Utilities + /** + * @param {import('settings-modifications').ScopedModification[]} targets + * @param {string} source + * @returns {Promise<import('core').Response<import('settings-modifications').ModificationResult>[]>} + */ async _modifySettings(targets, source) { + /** @type {import('core').Response<import('settings-modifications').ModificationResult>[]} */ const results = []; for (const target of targets) { try { const result = this._modifySetting(target); results.push({result: clone(result)}); } catch (e) { - results.push({error: serializeError(e)}); + results.push({error: ExtensionError.serialize(e)}); } } await this._saveOptions(source); return results; } + /** + * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} + */ _getOrCreateSearchPopup() { if (this._searchPopupTabCreatePromise === null) { const promise = this._getOrCreateSearchPopup2(); @@ -894,9 +1086,16 @@ export class Backend { return this._searchPopupTabCreatePromise; } + /** + * @returns {Promise<{tab: chrome.tabs.Tab, created: boolean}>} + */ async _getOrCreateSearchPopup2() { // Use existing tab const baseUrl = chrome.runtime.getURL('/search.html'); + /** + * @param {?string} url + * @returns {boolean} + */ const urlPredicate = (url) => url !== null && url.startsWith(baseUrl); if (this._searchPopupTabId !== null) { const tab = await this._checkTabUrl(this._searchPopupTabId, urlPredicate); @@ -910,8 +1109,11 @@ export class Backend { const existingTabInfo = await this._findSearchPopupTab(urlPredicate); if (existingTabInfo !== null) { const existingTab = existingTabInfo.tab; - this._searchPopupTabId = existingTab.id; - return {tab: existingTab, created: false}; + const {id} = existingTab; + if (typeof id === 'number') { + this._searchPopupTabId = id; + return {tab: existingTab, created: false}; + } } // chrome.windows not supported (e.g. on Firefox mobile) @@ -920,38 +1122,48 @@ export class Backend { } // Create a new window - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); const createData = this._getSearchPopupWindowCreateData(baseUrl, options); const {popupWindow: {windowState}} = options; const popupWindow = await this._createWindow(createData); - if (windowState !== 'normal') { + if (windowState !== 'normal' && typeof popupWindow.id === 'number') { await this._updateWindow(popupWindow.id, {state: windowState}); } const {tabs} = popupWindow; - if (tabs.length === 0) { + if (!Array.isArray(tabs) || tabs.length === 0) { throw new Error('Created window did not contain a tab'); } const tab = tabs[0]; - await this._waitUntilTabFrameIsReady(tab.id, 0, 2000); + const {id} = tab; + if (typeof id !== 'number') { + throw new Error('Tab does not have an id'); + } + await this._waitUntilTabFrameIsReady(id, 0, 2000); await this._sendMessageTabPromise( - tab.id, + id, {action: 'SearchDisplayController.setMode', params: {mode: 'popup'}}, {frameId: 0} ); - this._searchPopupTabId = tab.id; + this._searchPopupTabId = id; return {tab, created: true}; } + /** + * @param {(url: ?string) => boolean} urlPredicate + * @returns {Promise<?import('backend').TabInfo>} + */ async _findSearchPopupTab(urlPredicate) { + /** @type {import('backend').FindTabsPredicate} */ const predicate = async ({url, tab}) => { - if (!urlPredicate(url)) { return false; } + const {id} = tab; + if (typeof id === 'undefined' || !urlPredicate(url)) { return false; } try { const mode = await this._sendMessageTabPromise( - tab.id, + id, {action: 'SearchDisplayController.getMode', params: {}}, {frameId: 0} ); @@ -960,9 +1172,14 @@ export class Backend { return false; } }; - return await this._findTabs(1000, false, predicate, true); + return /** @type {?import('backend').TabInfo} */ (await this._findTabs(1000, false, predicate, true)); } + /** + * @param {string} url + * @param {import('settings').ProfileOptions} options + * @returns {chrome.windows.CreateData} + */ _getSearchPopupWindowCreateData(url, options) { const {popupWindow: {width, height, left, top, useLeft, useTop, windowType}} = options; return { @@ -976,6 +1193,10 @@ export class Backend { }; } + /** + * @param {chrome.windows.CreateData} createData + * @returns {Promise<chrome.windows.Window>} + */ _createWindow(createData) { return new Promise((resolve, reject) => { chrome.windows.create( @@ -985,13 +1206,18 @@ export class Backend { if (error) { reject(new Error(error.message)); } else { - resolve(result); + resolve(/** @type {chrome.windows.Window} */ (result)); } } ); }); } + /** + * @param {number} windowId + * @param {chrome.windows.UpdateInfo} updateInfo + * @returns {Promise<chrome.windows.Window>} + */ _updateWindow(windowId, updateInfo) { return new Promise((resolve, reject) => { chrome.windows.update( @@ -1009,21 +1235,31 @@ export class Backend { }); } - _updateSearchQuery(tabId, text, animate) { - return this._sendMessageTabPromise( + /** + * @param {number} tabId + * @param {string} text + * @param {boolean} animate + * @returns {Promise<void>} + */ + async _updateSearchQuery(tabId, text, animate) { + await this._sendMessageTabPromise( tabId, {action: 'SearchDisplayController.updateSearchQuery', params: {text, animate}}, {frameId: 0} ); } + /** + * @param {string} source + */ _applyOptions(source) { - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); this._updateBadge(); const enabled = options.general.enable; - let {apiKey} = options.anki; + /** @type {?string} */ + let apiKey = options.anki.apiKey; if (apiKey === '') { apiKey = null; } this._anki.server = options.anki.server; this._anki.enabled = options.anki.enable && enabled; @@ -1042,16 +1278,33 @@ export class Backend { this._sendMessageAllTabsIgnoreResponse('Yomitan.optionsUpdated', {source}); } - _getOptionsFull(useSchema=false) { + /** + * @param {boolean} useSchema + * @returns {import('settings').Options} + * @throws {Error} + */ + _getOptionsFull(useSchema) { const options = this._options; - return useSchema ? this._optionsUtil.createValidatingProxy(options) : options; + if (options === null) { throw new Error('Options is null'); } + return useSchema ? /** @type {import('settings').Options} */ (this._optionsUtil.createValidatingProxy(options)) : options; } - _getProfileOptions(optionsContext, useSchema=false) { + /** + * @param {import('settings').OptionsContext} optionsContext + * @param {boolean} useSchema + * @returns {import('settings').ProfileOptions} + */ + _getProfileOptions(optionsContext, useSchema) { return this._getProfile(optionsContext, useSchema).options; } - _getProfile(optionsContext, useSchema=false) { + /** + * @param {import('settings').OptionsContext} optionsContext + * @param {boolean} useSchema + * @returns {import('settings').Profile} + * @throws {Error} + */ + _getProfile(optionsContext, useSchema) { const options = this._getOptionsFull(useSchema); const profiles = options.profiles; if (!optionsContext.current) { @@ -1077,8 +1330,13 @@ export class Backend { return profiles[profileCurrent]; } + /** + * @param {import('settings').Options} options + * @param {import('settings').OptionsContext} optionsContext + * @returns {?import('settings').Profile} + */ _getProfileFromContext(options, optionsContext) { - optionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); + const normalizedOptionsContext = this._profileConditionsUtil.normalizeContext(optionsContext); let index = 0; for (const profile of options.profiles) { @@ -1092,7 +1350,7 @@ export class Backend { this._profileConditionsSchemaCache.push(schema); } - if (conditionGroups.length > 0 && schema.isValid(optionsContext)) { + if (conditionGroups.length > 0 && schema.isValid(normalizedOptionsContext)) { return profile; } ++index; @@ -1101,20 +1359,36 @@ export class Backend { return null; } + /** + * @param {string} message + * @param {unknown} data + * @returns {ExtensionError} + */ _createDataError(message, data) { - const error = new Error(message); + const error = new ExtensionError(message); error.data = data; return error; } + /** + * @returns {void} + */ _clearProfileConditionsSchemaCache() { this._profileConditionsSchemaCache = []; } - _checkLastError() { + /** + * @param {unknown} _ignore + */ + _checkLastError(_ignore) { // NOP } + /** + * @param {string} command + * @param {import('core').SerializableObject|undefined} params + * @returns {boolean} + */ _runCommand(command, params) { const handler = this._commandHandlers.get(command); if (typeof handler !== 'function') { return false; } @@ -1123,12 +1397,20 @@ export class Backend { return true; } + /** + * @param {string} text + * @param {number} scanLength + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<import('api').ParseTextLine[]>} + */ async _textParseScanning(text, scanLength, optionsContext) { const jp = this._japaneseUtil; + /** @type {import('translator').FindTermsMode} */ const mode = 'simple'; - const options = this._getProfileOptions(optionsContext); - const details = {matchType: 'exact', deinflect: true}; + const options = this._getProfileOptions(optionsContext, false); + const details = {matchType: /** @type {import('translation').FindTermsMatchType} */ ('exact'), deinflect: true}; const findTermsOptions = this._getTranslatorFindTermsOptions(mode, details, options); + /** @type {import('api').ParseTextLine[]} */ const results = []; let previousUngroupedSegment = null; let i = 0; @@ -1139,7 +1421,7 @@ export class Backend { text.substring(i, i + scanLength), findTermsOptions ); - const codePoint = text.codePointAt(i); + const codePoint = /** @type {number} */ (text.codePointAt(i)); const character = String.fromCodePoint(codePoint); if ( dictionaryEntries.length > 0 && @@ -1168,6 +1450,10 @@ export class Backend { return results; } + /** + * @param {string} text + * @returns {Promise<import('backend').MecabParseResults>} + */ async _textParseMecab(text) { const jp = this._japaneseUtil; @@ -1178,8 +1464,10 @@ export class Backend { return []; } + /** @type {import('backend').MecabParseResults} */ const results = []; for (const {name, lines} of parseTextResults) { + /** @type {import('api').ParseTextLine[]} */ const result = []; for (const line of lines) { for (const {term, reading, source} of line) { @@ -1200,30 +1488,43 @@ export class Backend { return results; } + /** + * @param {chrome.runtime.Port} port + * @param {chrome.runtime.MessageSender} sender + * @param {import('backend').MessageHandlerWithProgressMap} handlers + */ _createActionListenerPort(port, sender, handlers) { + let done = false; let hasStarted = false; + /** @type {?string} */ let messageString = ''; + /** + * @param {...unknown} data + */ const onProgress = (...data) => { try { - if (port === null) { return; } - port.postMessage({type: 'progress', data}); + if (done) { return; } + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseProgressMessage} */ ({type: 'progress', data})); } catch (e) { // NOP } }; + /** + * @param {import('backend').InvokeWithProgressRequestMessage} message + */ const onMessage = (message) => { if (hasStarted) { return; } try { - const {action, data} = message; + const {action} = message; switch (action) { case 'fragment': - messageString += data; + messageString += message.data; break; case 'invoke': - { + if (messageString !== null) { hasStarted = true; port.onMessage.removeListener(onMessage); @@ -1238,10 +1539,13 @@ export class Backend { } }; + /** + * @param {{action: string, params?: import('core').SerializableObject}} message + */ const onMessageComplete = async (message) => { try { const {action, params} = message; - port.postMessage({type: 'ack'}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseAcknowledgeMessage} */ ({type: 'ack'})); const messageHandler = handlers.get(action); if (typeof messageHandler === 'undefined') { @@ -1255,7 +1559,7 @@ export class Backend { const promiseOrResult = handler(params, sender, onProgress); const result = async ? await promiseOrResult : promiseOrResult; - port.postMessage({type: 'complete', data: result}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseCompleteMessage} */ ({type: 'complete', data: result})); } catch (e) { cleanup(e); } @@ -1265,23 +1569,29 @@ export class Backend { cleanup(null); }; + /** + * @param {unknown} error + */ const cleanup = (error) => { - if (port === null) { return; } + if (done) { return; } if (error !== null) { - port.postMessage({type: 'error', data: serializeError(error)}); + port.postMessage(/** @type {import('backend').InvokeWithProgressResponseErrorMessage} */ ({type: 'error', data: ExtensionError.serialize(error)})); } if (!hasStarted) { port.onMessage.removeListener(onMessage); } port.onDisconnect.removeListener(onDisconnect); - port = null; - handlers = null; + done = true; }; port.onMessage.addListener(onMessage); port.onDisconnect.addListener(onDisconnect); } + /** + * @param {?import('log').LogLevel} errorLevel + * @returns {number} + */ _getErrorLevelValue(errorLevel) { switch (errorLevel) { case 'info': return 0; @@ -1292,19 +1602,32 @@ export class Backend { } } + /** + * @param {import('settings-modifications').OptionsScope} target + * @returns {import('settings').Options|import('settings').ProfileOptions} + * @throws {Error} + */ _getModifySettingObject(target) { const scope = target.scope; switch (scope) { case 'profile': - if (!isObject(target.optionsContext)) { throw new Error('Invalid optionsContext'); } - return this._getProfileOptions(target.optionsContext, true); + { + const {optionsContext} = target; + if (typeof optionsContext !== 'object' || optionsContext === null) { throw new Error('Invalid optionsContext'); } + return /** @type {import('settings').ProfileOptions} */ (this._getProfileOptions(optionsContext, true)); + } case 'global': - return this._getOptionsFull(true); + return /** @type {import('settings').Options} */ (this._getOptionsFull(true)); default: throw new Error(`Invalid scope: ${scope}`); } } + /** + * @param {import('settings-modifications').OptionsScope&import('settings-modifications').Read} target + * @returns {unknown} + * @throws {Error} + */ _getSetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); @@ -1313,6 +1636,11 @@ export class Backend { return accessor.get(ObjectPropertyAccessor.getPathArray(path)); } + /** + * @param {import('settings-modifications').ScopedModification} target + * @returns {import('settings-modifications').ModificationResult} + * @throws {Error} + */ _modifySetting(target) { const options = this._getModifySettingObject(target); const accessor = new ObjectPropertyAccessor(options); @@ -1368,10 +1696,14 @@ export class Backend { } } + /** + * @param {chrome.runtime.MessageSender} sender + * @throws {Error} + */ _validatePrivilegedMessageSender(sender) { let {url} = sender; if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } - const {tab} = url; + const {tab} = sender; if (typeof tab === 'object' && tab !== null) { ({url} = tab); if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } @@ -1379,6 +1711,9 @@ export class Backend { throw new Error('Invalid message sender'); } + /** + * @returns {Promise<string>} + */ _getBrowserIconTitle() { return ( isObject(chrome.action) && @@ -1388,6 +1723,9 @@ export class Backend { ); } + /** + * @returns {void} + */ _updateBadge() { let title = this._defaultBrowserActionTitle; if (title === null || !isObject(chrome.action)) { @@ -1423,7 +1761,7 @@ export class Backend { status = 'Loading'; } } else { - const options = this._getProfileOptions({current: true}); + const options = this._getProfileOptions({current: true}, false); if (!options.general.enable) { text = 'off'; color = '#555555'; @@ -1453,6 +1791,10 @@ export class Backend { } } + /** + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ _isAnyDictionaryEnabled(options) { for (const {enabled} of options.dictionaries) { if (enabled) { @@ -1462,21 +1804,18 @@ export class Backend { return false; } - _anyOptionsMatches(predicate) { - for (const {options} of this._options.profiles) { - const value = predicate(options); - if (value) { return value; } - } - return false; - } - + /** + * @param {number} tabId + * @returns {Promise<?string>} + */ async _getTabUrl(tabId) { try { - const {url} = await this._sendMessageTabPromise( + const response = await this._sendMessageTabPromise( tabId, {action: 'Yomitan.getUrl', params: {}}, {frameId: 0} ); + const url = typeof response === 'object' && response !== null ? /** @type {import('core').SerializableObject} */ (response).url : void 0; if (typeof url === 'string') { return url; } @@ -1486,6 +1825,9 @@ export class Backend { return null; } + /** + * @returns {Promise<chrome.tabs.Tab[]>} + */ _getAllTabs() { return new Promise((resolve, reject) => { chrome.tabs.query({}, (tabs) => { @@ -1499,21 +1841,33 @@ export class Backend { }); } + /** + * @param {number} timeout + * @param {boolean} multiple + * @param {import('backend').FindTabsPredicate} predicate + * @param {boolean} predicateIsAsync + * @returns {Promise<import('backend').TabInfo[]|(?import('backend').TabInfo)>} + */ async _findTabs(timeout, multiple, predicate, predicateIsAsync) { // This function works around the need to have the "tabs" permission to access tab.url. const tabs = await this._getAllTabs(); let done = false; + /** + * @param {chrome.tabs.Tab} tab + * @param {(tabInfo: import('backend').TabInfo) => boolean} add + */ const checkTab = async (tab, add) => { - const url = await this._getTabUrl(tab.id); + const {id} = tab; + const url = typeof id === 'number' ? await this._getTabUrl(id) : null; if (done) { return; } let okay = false; const item = {tab, url}; try { - okay = predicate(item); - if (predicateIsAsync) { okay = await okay; } + const okayOrPromise = predicate(item); + okay = predicateIsAsync ? await okayOrPromise : /** @type {boolean} */ (okayOrPromise); } catch (e) { // NOP } @@ -1526,7 +1880,12 @@ export class Backend { }; if (multiple) { + /** @type {import('backend').TabInfo[]} */ const results = []; + /** + * @param {import('backend').TabInfo} value + * @returns {boolean} + */ const add = (value) => { results.push(value); return false; @@ -1538,8 +1897,13 @@ export class Backend { ]); return results; } else { - const {promise, resolve} = deferPromise(); + const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + /** @type {?import('backend').TabInfo} */ let result = null; + /** + * @param {import('backend').TabInfo} value + * @returns {boolean} + */ const add = (value) => { result = value; resolve(); @@ -1556,9 +1920,17 @@ export class Backend { } } + /** + * @param {chrome.tabs.Tab} tab + */ async _focusTab(tab) { - await new Promise((resolve, reject) => { - chrome.tabs.update(tab.id, {active: true}, () => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { + const {id} = tab; + if (typeof id !== 'number') { + reject(new Error('Cannot focus a tab without an id')); + return; + } + chrome.tabs.update(id, {active: true}, () => { const e = chrome.runtime.lastError; if (e) { reject(new Error(e.message)); @@ -1566,7 +1938,7 @@ export class Backend { resolve(); } }); - }); + })); if (!(typeof chrome.windows === 'object' && chrome.windows !== null)) { // Windows not supported (e.g. on Firefox mobile) @@ -1585,7 +1957,7 @@ export class Backend { }); }); if (!tabWindow.focused) { - await new Promise((resolve, reject) => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.windows.update(tab.windowId, {focused: true}, () => { const e = chrome.runtime.lastError; if (e) { @@ -1594,23 +1966,31 @@ export class Backend { resolve(); } }); - }); + })); } } catch (e) { // Edge throws exception for no reason here. } } + /** + * @param {number} tabId + * @param {number} frameId + * @param {?number} [timeout=null] + * @returns {Promise<void>} + */ _waitUntilTabFrameIsReady(tabId, frameId, timeout=null) { return new Promise((resolve, reject) => { + /** @type {?import('core').Timeout} */ let timer = null; + /** @type {?import('extension').ChromeRuntimeOnMessageCallback} */ let onMessage = (message, sender) => { if ( !sender.tab || sender.tab.id !== tabId || sender.frameId !== frameId || - !isObject(message) || - message.action !== 'yomitanReady' + !(typeof message === 'object' && message !== null) || + /** @type {import('core').SerializableObject} */ (message).action !== 'yomitanReady' ) { return; } @@ -1651,7 +2031,11 @@ export class Backend { }); } - async _fetchAsset(url, json=false) { + /** + * @param {string} url + * @returns {Promise<Response>} + */ + async _fetchAsset(url) { const response = await fetch(chrome.runtime.getURL(url), { method: 'GET', mode: 'no-cors', @@ -1663,30 +2047,71 @@ export class Backend { if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status}`); } - return await (json ? response.json() : response.text()); + return response; + } + + /** + * @param {string} url + * @returns {Promise<string>} + */ + async _fetchText(url) { + const response = await this._fetchAsset(url); + return await response.text(); + } + + /** + * @param {string} url + * @returns {Promise<unknown>} + */ + async _fetchJson(url) { + const response = await this._fetchAsset(url); + return await response.json(); } - _sendMessageIgnoreResponse(...args) { + /** + * @param {{action: string, params: import('core').SerializableObject}} message + */ + _sendMessageIgnoreResponse(message) { const callback = () => this._checkLastError(chrome.runtime.lastError); - chrome.runtime.sendMessage(...args, callback); + chrome.runtime.sendMessage(message, callback); } - _sendMessageTabIgnoreResponse(...args) { + /** + * @param {number} tabId + * @param {{action: string, params?: import('core').SerializableObject, frameId?: number}} message + * @param {chrome.tabs.MessageSendOptions} options + */ + _sendMessageTabIgnoreResponse(tabId, message, options) { const callback = () => this._checkLastError(chrome.runtime.lastError); - chrome.tabs.sendMessage(...args, callback); + chrome.tabs.sendMessage(tabId, message, options, callback); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + */ _sendMessageAllTabsIgnoreResponse(action, params) { const callback = () => this._checkLastError(chrome.runtime.lastError); chrome.tabs.query({}, (tabs) => { for (const tab of tabs) { - chrome.tabs.sendMessage(tab.id, {action, params}, callback); + const {id} = tab; + if (typeof id !== 'number') { continue; } + chrome.tabs.sendMessage(id, {action, params}, callback); } }); } - _sendMessageTabPromise(...args) { + /** + * @param {number} tabId + * @param {{action: string, params?: import('core').SerializableObject}} message + * @param {chrome.tabs.MessageSendOptions} options + * @returns {Promise<unknown>} + */ + _sendMessageTabPromise(tabId, message, options) { return new Promise((resolve, reject) => { + /** + * @param {unknown} response + */ const callback = (response) => { try { resolve(this._getMessageResponseResult(response)); @@ -1695,25 +2120,35 @@ export class Backend { } }; - chrome.tabs.sendMessage(...args, callback); + chrome.tabs.sendMessage(tabId, message, options, callback); }); } + /** + * @param {unknown} response + * @returns {unknown} + * @throws {Error} + */ _getMessageResponseResult(response) { - let error = chrome.runtime.lastError; + const error = chrome.runtime.lastError; if (error) { throw new Error(error.message); } - if (!isObject(response)) { + if (typeof response !== 'object' || response === null) { throw new Error('Tab did not respond'); } - error = response.error; - if (error) { - throw deserializeError(error); + const responseError = /** @type {import('core').SerializedError|undefined} */ (/** @type {import('core').SerializableObject} */ (response).error); + if (typeof responseError === 'object' && responseError !== null) { + throw ExtensionError.deserialize(responseError); } - return response.result; + return /** @type {import('core').SerializableObject} */ (response).result; } + /** + * @param {number} tabId + * @param {(url: ?string) => boolean} urlPredicate + * @returns {Promise<?chrome.tabs.Tab>} + */ async _checkTabUrl(tabId, urlPredicate) { let tab; try { @@ -1727,6 +2162,13 @@ export class Backend { return isValidTab ? tab : null; } + /** + * @param {number} tabId + * @param {number} frameId + * @param {'jpeg'|'png'} format + * @param {number} quality + * @returns {Promise<string>} + */ async _getScreenshot(tabId, frameId, format, quality) { const tab = await this._getTabById(tabId); const {windowId} = tab; @@ -1762,6 +2204,16 @@ export class Backend { } } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {?import('api').InjectAnkiNoteMediaAudioDetails} audioDetails + * @param {?import('api').InjectAnkiNoteMediaScreenshotDetails} screenshotDetails + * @param {?import('api').InjectAnkiNoteMediaClipboardDetails} clipboardDetails + * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails + * @returns {Promise<import('api').InjectAnkiNoteMediaResult>} + */ async _injectAnkNoteMedia(ankiConnect, timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) { let screenshotFileName = null; let clipboardImageFileName = null; @@ -1774,7 +2226,7 @@ export class Backend { screenshotFileName = await this._injectAnkiNoteScreenshot(ankiConnect, timestamp, definitionDetails, screenshotDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1782,7 +2234,7 @@ export class Backend { clipboardImageFileName = await this._injectAnkiNoteClipboardImage(ankiConnect, timestamp, definitionDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1790,7 +2242,7 @@ export class Backend { clipboardText = await this._clipboardReader.getText(false); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } try { @@ -1798,19 +2250,20 @@ export class Backend { audioFileName = await this._injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, audioDetails); } } catch (e) { - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } + /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ let dictionaryMedia; try { let errors2; ({results: dictionaryMedia, errors: errors2} = await this._injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, definitionDetails, dictionaryMediaDetails)); for (const error of errors2) { - errors.push(serializeError(error)); + errors.push(ExtensionError.serialize(error)); } } catch (e) { dictionaryMedia = []; - errors.push(serializeError(e)); + errors.push(ExtensionError.serialize(e)); } return { @@ -1823,16 +2276,17 @@ export class Backend { }; } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaAudioDetails} details + * @returns {Promise<?string>} + */ async _injectAnkiNoteAudio(ankiConnect, timestamp, definitionDetails, details) { - const {type, term, reading} = definitionDetails; - if ( - type === 'kanji' || - typeof term !== 'string' || - typeof reading !== 'string' || - (term.length === 0 && reading.length === 0) - ) { - return null; - } + if (definitionDetails.type !== 'term') { return null; } + const {term, reading} = definitionDetails; + if (term.length === 0 && reading.length === 0) { return null; } const {sources, preferredAudioIndex, idleTimeout} = details; let data; @@ -1852,15 +2306,20 @@ export class Backend { return null; } - let extension = MediaUtil.getFileExtensionFromAudioMediaType(contentType); + let extension = contentType !== null ? MediaUtil.getFileExtensionFromAudioMediaType(contentType) : null; if (extension === null) { extension = '.mp3'; } let fileName = this._generateAnkiNoteMediaFileName('yomitan_audio', extension, timestamp, definitionDetails); fileName = fileName.replace(/\]/g, ''); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaScreenshotDetails} details + * @returns {Promise<?string>} + */ async _injectAnkiNoteScreenshot(ankiConnect, timestamp, definitionDetails, details) { const {tabId, frameId, format, quality} = details; const dataUrl = await this._getScreenshot(tabId, frameId, format, quality); @@ -1871,12 +2330,16 @@ export class Backend { throw new Error('Unknown media type for screenshot image'); } - let fileName = this._generateAnkiNoteMediaFileName('yomitan_browser_screenshot', extension, timestamp, definitionDetails); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + const fileName = this._generateAnkiNoteMediaFileName('yomitan_browser_screenshot', extension, timestamp, definitionDetails); + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @returns {Promise<?string>} + */ async _injectAnkiNoteClipboardImage(ankiConnect, timestamp, definitionDetails) { const dataUrl = await this._clipboardReader.getImage(); if (dataUrl === null) { @@ -1889,12 +2352,17 @@ export class Backend { throw new Error('Unknown media type for clipboard image'); } - let fileName = this._generateAnkiNoteMediaFileName('yomitan_clipboard_image', extension, timestamp, definitionDetails); - fileName = await ankiConnect.storeMediaFile(fileName, data); - - return fileName; + const fileName = this._generateAnkiNoteMediaFileName('yomitan_clipboard_image', extension, timestamp, definitionDetails); + return await ankiConnect.storeMediaFile(fileName, data); } + /** + * @param {AnkiConnect} ankiConnect + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @param {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} dictionaryMediaDetails + * @returns {Promise<{results: import('api').InjectAnkiNoteDictionaryMediaResult[], errors: unknown[]}>} + */ async _injectAnkiNoteDictionaryMedia(ankiConnect, timestamp, definitionDetails, dictionaryMediaDetails) { const targets = []; const detailsList = []; @@ -1918,6 +2386,7 @@ export class Backend { } const errors = []; + /** @type {import('api').InjectAnkiNoteDictionaryMediaResult[]} */ const results = []; for (let i = 0, ii = detailsList.length; i < ii; ++i) { const {dictionary, path, media} = detailsList[i]; @@ -1925,7 +2394,12 @@ export class Backend { if (media !== null) { const {content, mediaType} = media; const extension = MediaUtil.getFileExtensionFromImageMediaType(mediaType); - fileName = this._generateAnkiNoteMediaFileName(`yomitan_dictionary_media_${i + 1}`, extension, timestamp, definitionDetails); + fileName = this._generateAnkiNoteMediaFileName( + `yomitan_dictionary_media_${i + 1}`, + extension !== null ? extension : '', + timestamp, + definitionDetails + ); try { fileName = await ankiConnect.storeMediaFile(fileName, content); } catch (e) { @@ -1939,18 +2413,27 @@ export class Backend { return {results, errors}; } + /** + * @param {unknown} error + * @returns {?ExtensionError} + */ _getAudioDownloadError(error) { - if (isObject(error.data)) { - const {errors} = error.data; + if (error instanceof ExtensionError && typeof error.data === 'object' && error.data !== null) { + const {errors} = /** @type {import('core').SerializableObject} */ (error.data); if (Array.isArray(errors)) { for (const error2 of errors) { + if (!(error2 instanceof Error)) { continue; } if (error2.name === 'AbortError') { return this._createAudioDownloadError('Audio download was cancelled due to an idle timeout', 'audio-download-idle-timeout', errors); } - if (!isObject(error2.data)) { continue; } - const {details} = error2.data; - if (!isObject(details)) { continue; } - switch (details.error) { + if (!(error2 instanceof ExtensionError)) { continue; } + const {data} = error2; + if (!(typeof data === 'object' && data !== null)) { continue; } + const {details} = /** @type {import('core').SerializableObject} */ (data); + if (!(typeof details === 'object' && details !== null)) { continue; } + const error3 = /** @type {import('core').SerializableObject} */ (details).error; + if (typeof error3 !== 'string') { continue; } + switch (error3) { case 'net::ERR_FAILED': // This is potentially an error due to the extension not having enough URL privileges. // The message logged to the console looks like this: @@ -1967,23 +2450,38 @@ export class Backend { return null; } + /** + * @param {string} message + * @param {?string} issueId + * @param {?(Error[])} errors + * @returns {ExtensionError} + */ _createAudioDownloadError(message, issueId, errors) { - const error = new Error(message); + const error = new ExtensionError(message); const hasErrors = Array.isArray(errors); const hasIssueId = (typeof issueId === 'string'); if (hasErrors || hasIssueId) { + /** @type {{errors?: import('core').SerializedError[], referenceUrl?: string}} */ + const data = {}; error.data = {}; if (hasErrors) { // Errors need to be serialized since they are passed to other frames - error.data.errors = errors.map((e) => serializeError(e)); + data.errors = errors.map((e) => ExtensionError.serialize(e)); } if (hasIssueId) { - error.data.referenceUrl = `/issues.html#${issueId}`; + data.referenceUrl = `/issues.html#${issueId}`; } } return error; } + /** + * @param {string} prefix + * @param {string} extension + * @param {number} timestamp + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} definitionDetails + * @returns {string} + */ _generateAnkiNoteMediaFileName(prefix, extension, timestamp, definitionDetails) { let fileName = prefix; @@ -2011,11 +2509,19 @@ export class Backend { return fileName; } + /** + * @param {string} fileName + * @returns {string} + */ _replaceInvalidFileNameCharacters(fileName) { // eslint-disable-next-line no-control-regex return fileName.replace(/[<>:"/\\|?*\x00-\x1F]/g, '-'); } + /** + * @param {Date} date + * @returns {string} + */ _ankNoteDateToString(date) { const year = date.getUTCFullYear(); const month = date.getUTCMonth().toString().padStart(2, '0'); @@ -2026,6 +2532,11 @@ export class Backend { return `${year}-${month}-${day}-${hours}-${minutes}-${seconds}`; } + /** + * @param {string} dataUrl + * @returns {{mediaType: string, data: string}} + * @throws {Error} + */ _getDataUrlInfo(dataUrl) { const match = /^data:([^,]*?)(;base64)?,/.exec(dataUrl); if (match === null) { @@ -2041,28 +2552,35 @@ export class Backend { return {mediaType, data}; } + /** + * @param {import('backend').DatabaseUpdateType} type + * @param {import('backend').DatabaseUpdateCause} cause + */ _triggerDatabaseUpdated(type, cause) { this._translator.clearDatabaseCaches(); this._sendMessageAllTabsIgnoreResponse('Yomitan.databaseUpdated', {type, cause}); } + /** + * @param {string} source + */ async _saveOptions(source) { this._clearProfileConditionsSchemaCache(); - const options = this._getOptionsFull(); + const options = this._getOptionsFull(false); await this._optionsUtil.save(options); this._applyOptions(source); } /** * Creates an options object for use with `Translator.findTerms`. - * @param {string} mode The display mode for the dictionary entries. - * @param {{matchType: string, deinflect: boolean}} details Custom info for finding terms. - * @param {object} options The options. - * @returns {FindTermsOptions} An options object. + * @param {import('translator').FindTermsMode} mode The display mode for the dictionary entries. + * @param {import('api').FindTermsDetails} details Custom info for finding terms. + * @param {import('settings').ProfileOptions} options The options. + * @returns {import('translation').FindTermsOptions} An options object. */ _getTranslatorFindTermsOptions(mode, details, options) { let {matchType, deinflect} = details; - if (typeof matchType !== 'string') { matchType = 'exact'; } + if (typeof matchType !== 'string') { matchType = /** @type {import('translation').FindTermsMatchType} */ ('exact'); } if (typeof deinflect !== 'boolean') { deinflect = true; } const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); const { @@ -2110,14 +2628,18 @@ export class Backend { /** * Creates an options object for use with `Translator.findKanji`. - * @param {object} options The options. - * @returns {FindKanjiOptions} An options object. + * @param {import('settings').ProfileOptions} options The options. + * @returns {import('translation').FindKanjiOptions} An options object. */ _getTranslatorFindKanjiOptions(options) { const enabledDictionaryMap = this._getTranslatorEnabledDictionaryMap(options); return {enabledDictionaryMap}; } + /** + * @param {import('settings').ProfileOptions} options + * @returns {Map<string, import('translation').FindTermDictionary>} + */ _getTranslatorEnabledDictionaryMap(options) { const enabledDictionaryMap = new Map(); for (const dictionary of options.dictionaries) { @@ -2131,18 +2653,25 @@ export class Backend { return enabledDictionaryMap; } + /** + * @param {import('settings').TranslationTextReplacementOptions} textReplacementsOptions + * @returns {(?(import('translation').FindTermsTextReplacement[]))[]} + */ _getTranslatorTextReplacements(textReplacementsOptions) { + /** @type {(?(import('translation').FindTermsTextReplacement[]))[]} */ const textReplacements = []; for (const group of textReplacementsOptions.groups) { + /** @type {import('translation').FindTermsTextReplacement[]} */ const textReplacementsEntries = []; - for (let {pattern, ignoreCase, replacement} of group) { + for (const {pattern, ignoreCase, replacement} of group) { + let patternRegExp; try { - pattern = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); + patternRegExp = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); } catch (e) { // Invalid pattern continue; } - textReplacementsEntries.push({pattern, replacement}); + textReplacementsEntries.push({pattern: patternRegExp, replacement}); } if (textReplacementsEntries.length > 0) { textReplacements.push(textReplacementsEntries); @@ -2154,6 +2683,9 @@ export class Backend { return textReplacements; } + /** + * @returns {Promise<void>} + */ async _openWelcomeGuidePageOnce() { chrome.storage.session.get(['openedWelcomePage']).then((result) => { if (!result.openedWelcomePage) { @@ -2163,20 +2695,33 @@ export class Backend { }); } + /** + * @returns {Promise<void>} + */ async _openWelcomeGuidePage() { await this._createTab(chrome.runtime.getURL('/welcome.html')); } + /** + * @returns {Promise<void>} + */ async _openInfoPage() { await this._createTab(chrome.runtime.getURL('/info.html')); } + /** + * @param {'existingOrNewTab'|'newTab'} mode + */ async _openSettingsPage(mode) { const manifest = chrome.runtime.getManifest(); - const url = chrome.runtime.getURL(manifest.options_ui.page); + const optionsUI = manifest.options_ui; + if (typeof optionsUI === 'undefined') { throw new Error('Failed to find options_ui'); } + const {page} = optionsUI; + if (typeof page === 'undefined') { throw new Error('Failed to find options_ui.page'); } + const url = chrome.runtime.getURL(page); switch (mode) { case 'existingOrNewTab': - await new Promise((resolve, reject) => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.runtime.openOptionsPage(() => { const e = chrome.runtime.lastError; if (e) { @@ -2185,7 +2730,7 @@ export class Backend { resolve(); } }); - }); + })); break; case 'newTab': await this._createTab(url); @@ -2193,6 +2738,10 @@ export class Backend { } } + /** + * @param {string} url + * @returns {Promise<chrome.tabs.Tab>} + */ _createTab(url) { return new Promise((resolve, reject) => { chrome.tabs.create({url}, (tab) => { @@ -2206,6 +2755,10 @@ export class Backend { }); } + /** + * @param {number} tabId + * @returns {Promise<chrome.tabs.Tab>} + */ _getTabById(tabId) { return new Promise((resolve, reject) => { chrome.tabs.get( @@ -2222,20 +2775,33 @@ export class Backend { }); } + /** + * @returns {Promise<void>} + */ async _checkPermissions() { this._permissions = await this._permissionsUtil.getAllPermissions(); this._updateBadge(); } + /** + * @returns {boolean} + */ _canObservePermissionsChanges() { return isObject(chrome.permissions) && isObject(chrome.permissions.onAdded) && isObject(chrome.permissions.onRemoved); } + /** + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ _hasRequiredPermissionsForSettings(options) { if (!this._canObservePermissionsChanges()) { return true; } return this._permissions === null || this._permissionsUtil.hasRequiredPermissionsForOptions(this._permissions, options); } + /** + * @returns {Promise<void>} + */ async _requestPersistentStorage() { try { if (await navigator.storage.persisted()) { return; } @@ -2257,14 +2823,32 @@ export class Backend { } } + /** + * @param {{path: string, dictionary: string}[]} targets + * @returns {Promise<import('dictionary-database').MediaDataStringContent[]>} + */ async _getNormalizedDictionaryDatabaseMedia(targets) { - const results = await this._dictionaryDatabase.getMedia(targets); - for (const item of results) { - const {content} = item; - if (content instanceof ArrayBuffer) { - item.content = ArrayBufferUtil.arrayBufferToBase64(content); - } + const results = []; + for (const item of await this._dictionaryDatabase.getMedia(targets)) { + const {content, dictionary, height, mediaType, path, width} = item; + const content2 = ArrayBufferUtil.arrayBufferToBase64(content); + results.push({content: content2, dictionary, height, mediaType, path, width}); } return results; } + + /** + * @param {unknown} mode + * @param {'existingOrNewTab'|'newTab'} defaultValue + * @returns {'existingOrNewTab'|'newTab'} + */ + _normalizeOpenSettingsPageMode(mode, defaultValue) { + switch (mode) { + case 'existingOrNewTab': + case 'newTab': + return mode; + default: + return defaultValue; + } + } } diff --git a/ext/js/background/offscreen-proxy.js b/ext/js/background/offscreen-proxy.js index c01f523d..63f619fa 100644 --- a/ext/js/background/offscreen-proxy.js +++ b/ext/js/background/offscreen-proxy.js @@ -16,15 +16,19 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, isObject} from '../core.js'; +import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; export class OffscreenProxy { constructor() { + /** @type {?Promise<void>} */ this._creatingOffscreen = null; } - // https://developer.chrome.com/docs/extensions/reference/offscreen/ + /** + * @see https://developer.chrome.com/docs/extensions/reference/offscreen/ + */ async prepare() { if (await this._hasOffscreenDocument()) { return; @@ -36,20 +40,30 @@ export class OffscreenProxy { this._creatingOffscreen = chrome.offscreen.createDocument({ url: 'offscreen.html', - reasons: ['CLIPBOARD'], + reasons: [ + /** @type {chrome.offscreen.Reason} */ ('CLIPBOARD') + ], justification: 'Access to the clipboard' }); await this._creatingOffscreen; this._creatingOffscreen = null; } + /** + * @returns {Promise<boolean>} + */ async _hasOffscreenDocument() { const offscreenUrl = chrome.runtime.getURL('offscreen.html'); - if (!chrome.runtime.getContexts) { // chrome version <116 + // @ts-expect-error - API not defined yet + if (!chrome.runtime.getContexts) { // chrome version below 116 + // Clients: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/clients + // @ts-expect-error - Types not set up for service workers yet const matchedClients = await clients.matchAll(); + // @ts-expect-error - Types not set up for service workers yet return await matchedClients.some((client) => client.url === offscreenUrl); } + // @ts-expect-error - API not defined yet const contexts = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'], documentUrls: [offscreenUrl] @@ -57,116 +71,186 @@ export class OffscreenProxy { return !!contexts.length; } - sendMessagePromise(...args) { + /** + * @template {import('offscreen').MessageType} TMessageType + * @param {import('offscreen').Message<TMessageType>} message + * @returns {Promise<import('offscreen').MessageReturn<TMessageType>>} + */ + sendMessagePromise(message) { return new Promise((resolve, reject) => { - const callback = (response) => { + chrome.runtime.sendMessage(message, (response) => { try { resolve(this._getMessageResponseResult(response)); } catch (error) { reject(error); } - }; - - chrome.runtime.sendMessage(...args, callback); + }); }); } + /** + * @template [TReturn=unknown] + * @param {import('core').Response<TReturn>} response + * @returns {TReturn} + * @throws {Error} + */ _getMessageResponseResult(response) { - let error = chrome.runtime.lastError; + const error = chrome.runtime.lastError; if (error) { throw new Error(error.message); } if (!isObject(response)) { throw new Error('Offscreen document did not respond'); } - error = response.error; - if (error) { - throw deserializeError(error); + const error2 = response.error; + if (error2) { + throw ExtensionError.deserialize(error2); } return response.result; } } export class DictionaryDatabaseProxy { + /** + * @param {OffscreenProxy} offscreen + */ constructor(offscreen) { + /** @type {OffscreenProxy} */ this._offscreen = offscreen; } - prepare() { - return this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'}); + /** + * @returns {Promise<void>} + */ + async prepare() { + await this._offscreen.sendMessagePromise({action: 'databasePrepareOffscreen'}); } - getDictionaryInfo() { + /** + * @returns {Promise<import('dictionary-importer').Summary[]>} + */ + async getDictionaryInfo() { return this._offscreen.sendMessagePromise({action: 'getDictionaryInfoOffscreen'}); } - purge() { - return this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'}); + /** + * @returns {Promise<boolean>} + */ + async purge() { + return await this._offscreen.sendMessagePromise({action: 'databasePurgeOffscreen'}); } + /** + * @param {import('dictionary-database').MediaRequest[]} targets + * @returns {Promise<import('dictionary-database').Media[]>} + */ async getMedia(targets) { - const serializedMedia = await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}}); + const serializedMedia = /** @type {import('dictionary-database').Media<string>[]} */ (await this._offscreen.sendMessagePromise({action: 'databaseGetMediaOffscreen', params: {targets}})); const media = serializedMedia.map((m) => ({...m, content: ArrayBufferUtil.base64ToArrayBuffer(m.content)})); return media; } } export class TranslatorProxy { + /** + * @param {OffscreenProxy} offscreen + */ constructor(offscreen) { + /** @type {OffscreenProxy} */ this._offscreen = offscreen; } - prepare(deinflectionReasons) { - return this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}}); + /** + * @param {import('deinflector').ReasonsRaw} deinflectionReasons + */ + async prepare(deinflectionReasons) { + await this._offscreen.sendMessagePromise({action: 'translatorPrepareOffscreen', params: {deinflectionReasons}}); } - async findKanji(text, findKanjiOptions) { - const enabledDictionaryMapList = [...findKanjiOptions.enabledDictionaryMap]; - const modifiedKanjiOptions = { - ...findKanjiOptions, + /** + * @param {string} text + * @param {import('translation').FindKanjiOptions} options + * @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>} + */ + async findKanji(text, options) { + const enabledDictionaryMapList = [...options.enabledDictionaryMap]; + /** @type {import('offscreen').FindKanjiOptionsOffscreen} */ + const modifiedOptions = { + ...options, enabledDictionaryMap: enabledDictionaryMapList }; - return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, findKanjiOptions: modifiedKanjiOptions}}); + return this._offscreen.sendMessagePromise({action: 'findKanjiOffscreen', params: {text, options: modifiedOptions}}); } - async findTerms(mode, text, findTermsOptions) { - const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = findTermsOptions; + /** + * @param {import('translator').FindTermsMode} mode + * @param {string} text + * @param {import('translation').FindTermsOptions} options + * @returns {Promise<import('translator').FindTermsResult>} + */ + async findTerms(mode, text, options) { + const {enabledDictionaryMap, excludeDictionaryDefinitions, textReplacements} = options; const enabledDictionaryMapList = [...enabledDictionaryMap]; const excludeDictionaryDefinitionsList = excludeDictionaryDefinitions ? [...excludeDictionaryDefinitions] : null; const textReplacementsSerialized = textReplacements.map((group) => { - if (!group) { - return group; - } - return group.map((opt) => ({...opt, pattern: opt.pattern.toString()})); + return group !== null ? group.map((opt) => ({...opt, pattern: opt.pattern.toString()})) : null; }); - const modifiedFindTermsOptions = { - ...findTermsOptions, + /** @type {import('offscreen').FindTermsOptionsOffscreen} */ + const modifiedOptions = { + ...options, enabledDictionaryMap: enabledDictionaryMapList, excludeDictionaryDefinitions: excludeDictionaryDefinitionsList, textReplacements: textReplacementsSerialized }; - return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, findTermsOptions: modifiedFindTermsOptions}}); + return this._offscreen.sendMessagePromise({action: 'findTermsOffscreen', params: {mode, text, options: modifiedOptions}}); } + /** + * @param {import('translator').TermReadingList} termReadingList + * @param {string[]} dictionaries + * @returns {Promise<import('translator').TermFrequencySimple[]>} + */ async getTermFrequencies(termReadingList, dictionaries) { return this._offscreen.sendMessagePromise({action: 'getTermFrequenciesOffscreen', params: {termReadingList, dictionaries}}); } - clearDatabaseCaches() { - return this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'}); + /** */ + async clearDatabaseCaches() { + await this._offscreen.sendMessagePromise({action: 'clearDatabaseCachesOffscreen'}); } } export class ClipboardReaderProxy { + /** + * @param {OffscreenProxy} offscreen + */ constructor(offscreen) { + /** @type {?import('environment').Browser} */ + this._browser = null; + /** @type {OffscreenProxy} */ this._offscreen = offscreen; } + /** @type {?import('environment').Browser} */ + get browser() { return this._browser; } + set browser(value) { + if (this._browser === value) { return; } + this._browser = value; + this._offscreen.sendMessagePromise({action: 'clipboardSetBrowserOffscreen', params: {value}}); + } + + /** + * @param {boolean} useRichText + * @returns {Promise<string>} + */ async getText(useRichText) { - return this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}}); + return await this._offscreen.sendMessagePromise({action: 'clipboardGetTextOffscreen', params: {useRichText}}); } + /** + * @returns {Promise<?string>} + */ async getImage() { - return this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'}); + return await this._offscreen.sendMessagePromise({action: 'clipboardGetImageOffscreen'}); } } diff --git a/ext/js/background/offscreen.js b/ext/js/background/offscreen.js index 27cee8c4..4b57514d 100644 --- a/ext/js/background/offscreen.js +++ b/ext/js/background/offscreen.js @@ -23,7 +23,6 @@ import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; import {DictionaryDatabase} from '../language/dictionary-database.js'; import {JapaneseUtil} from '../language/sandbox/japanese-util.js'; import {Translator} from '../language/translator.js'; -import {yomitan} from '../yomitan.js'; /** * This class controls the core logic of the extension, including API calls @@ -34,12 +33,16 @@ export class Offscreen { * Creates a new instance. */ constructor() { + /** @type {JapaneseUtil} */ this._japaneseUtil = new JapaneseUtil(wanakana); + /** @type {DictionaryDatabase} */ this._dictionaryDatabase = new DictionaryDatabase(); + /** @type {Translator} */ this._translator = new Translator({ japaneseUtil: this._japaneseUtil, database: this._dictionaryDatabase }); + /** @type {ClipboardReader} */ this._clipboardReader = new ClipboardReader({ // eslint-disable-next-line no-undef document: (typeof document === 'object' && document !== null ? document : null), @@ -47,35 +50,47 @@ export class Offscreen { richContentPasteTargetSelector: '#clipboard-rich-content-paste-target' }); - this._messageHandlers = new Map([ - ['clipboardGetTextOffscreen', {async: true, contentScript: true, handler: this._getTextHandler.bind(this)}], - ['clipboardGetImageOffscreen', {async: true, contentScript: true, handler: this._getImageHandler.bind(this)}], - ['databasePrepareOffscreen', {async: true, contentScript: true, handler: this._prepareDatabaseHandler.bind(this)}], - ['getDictionaryInfoOffscreen', {async: true, contentScript: true, handler: this._getDictionaryInfoHandler.bind(this)}], - ['databasePurgeOffscreen', {async: true, contentScript: true, handler: this._purgeDatabaseHandler.bind(this)}], - ['databaseGetMediaOffscreen', {async: true, contentScript: true, handler: this._getMediaHandler.bind(this)}], - ['translatorPrepareOffscreen', {async: false, contentScript: true, handler: this._prepareTranslatorHandler.bind(this)}], - ['findKanjiOffscreen', {async: true, contentScript: true, handler: this._findKanjiHandler.bind(this)}], - ['findTermsOffscreen', {async: true, contentScript: true, handler: this._findTermsHandler.bind(this)}], - ['getTermFrequenciesOffscreen', {async: true, contentScript: true, handler: this._getTermFrequenciesHandler.bind(this)}], - ['clearDatabaseCachesOffscreen', {async: false, contentScript: true, handler: this._clearDatabaseCachesHandler.bind(this)}] - + /** @type {import('offscreen').MessageHandlerMap} */ + const messageHandlers = new Map([ + ['clipboardGetTextOffscreen', {async: true, handler: this._getTextHandler.bind(this)}], + ['clipboardGetImageOffscreen', {async: true, handler: this._getImageHandler.bind(this)}], + ['clipboardSetBrowserOffscreen', {async: false, handler: this._setClipboardBrowser.bind(this)}], + ['databasePrepareOffscreen', {async: true, handler: this._prepareDatabaseHandler.bind(this)}], + ['getDictionaryInfoOffscreen', {async: true, handler: this._getDictionaryInfoHandler.bind(this)}], + ['databasePurgeOffscreen', {async: true, handler: this._purgeDatabaseHandler.bind(this)}], + ['databaseGetMediaOffscreen', {async: true, handler: this._getMediaHandler.bind(this)}], + ['translatorPrepareOffscreen', {async: false, handler: this._prepareTranslatorHandler.bind(this)}], + ['findKanjiOffscreen', {async: true, handler: this._findKanjiHandler.bind(this)}], + ['findTermsOffscreen', {async: true, handler: this._findTermsHandler.bind(this)}], + ['getTermFrequenciesOffscreen', {async: true, handler: this._getTermFrequenciesHandler.bind(this)}], + ['clearDatabaseCachesOffscreen', {async: false, handler: this._clearDatabaseCachesHandler.bind(this)}] ]); + /** @type {import('offscreen').MessageHandlerMap<string>} */ + this._messageHandlers = messageHandlers; const onMessage = this._onMessage.bind(this); chrome.runtime.onMessage.addListener(onMessage); + /** @type {?Promise<void>} */ this._prepareDatabasePromise = null; } - _getTextHandler({useRichText}) { - return this._clipboardReader.getText(useRichText); + /** @type {import('offscreen').MessageHandler<'clipboardGetTextOffscreen', true>} */ + async _getTextHandler({useRichText}) { + return await this._clipboardReader.getText(useRichText); + } + + /** @type {import('offscreen').MessageHandler<'clipboardGetImageOffscreen', true>} */ + async _getImageHandler() { + return await this._clipboardReader.getImage(); } - _getImageHandler() { - return this._clipboardReader.getImage(); + /** @type {import('offscreen').MessageHandler<'clipboardSetBrowserOffscreen', false>} */ + _setClipboardBrowser({value}) { + this._clipboardReader.browser = value; } + /** @type {import('offscreen').MessageHandler<'databasePrepareOffscreen', true>} */ _prepareDatabaseHandler() { if (this._prepareDatabasePromise !== null) { return this._prepareDatabasePromise; @@ -84,70 +99,79 @@ export class Offscreen { return this._prepareDatabasePromise; } - _getDictionaryInfoHandler() { - return this._dictionaryDatabase.getDictionaryInfo(); + /** @type {import('offscreen').MessageHandler<'getDictionaryInfoOffscreen', true>} */ + async _getDictionaryInfoHandler() { + return await this._dictionaryDatabase.getDictionaryInfo(); } - _purgeDatabaseHandler() { - return this._dictionaryDatabase.purge(); + /** @type {import('offscreen').MessageHandler<'databasePurgeOffscreen', true>} */ + async _purgeDatabaseHandler() { + return await this._dictionaryDatabase.purge(); } + /** @type {import('offscreen').MessageHandler<'databaseGetMediaOffscreen', true>} */ async _getMediaHandler({targets}) { const media = await this._dictionaryDatabase.getMedia(targets); const serializedMedia = media.map((m) => ({...m, content: ArrayBufferUtil.arrayBufferToBase64(m.content)})); return serializedMedia; } + /** @type {import('offscreen').MessageHandler<'translatorPrepareOffscreen', false>} */ _prepareTranslatorHandler({deinflectionReasons}) { - return this._translator.prepare(deinflectionReasons); + this._translator.prepare(deinflectionReasons); } - _findKanjiHandler({text, findKanjiOptions}) { - findKanjiOptions.enabledDictionaryMap = new Map(findKanjiOptions.enabledDictionaryMap); - return this._translator.findKanji(text, findKanjiOptions); + /** @type {import('offscreen').MessageHandler<'findKanjiOffscreen', true>} */ + async _findKanjiHandler({text, options}) { + /** @type {import('translation').FindKanjiOptions} */ + const modifiedOptions = { + ...options, + enabledDictionaryMap: new Map(options.enabledDictionaryMap) + }; + return await this._translator.findKanji(text, modifiedOptions); } - _findTermsHandler({mode, text, findTermsOptions}) { - findTermsOptions.enabledDictionaryMap = new Map(findTermsOptions.enabledDictionaryMap); - if (findTermsOptions.excludeDictionaryDefinitions) { - findTermsOptions.excludeDictionaryDefinitions = new Set(findTermsOptions.excludeDictionaryDefinitions); - } - findTermsOptions.textReplacements = findTermsOptions.textReplacements.map((group) => { - if (!group) { - return group; - } + /** @type {import('offscreen').MessageHandler<'findTermsOffscreen', true>} */ + _findTermsHandler({mode, text, options}) { + const enabledDictionaryMap = new Map(options.enabledDictionaryMap); + const excludeDictionaryDefinitions = ( + options.excludeDictionaryDefinitions !== null ? + new Set(options.excludeDictionaryDefinitions) : + null + ); + const textReplacements = options.textReplacements.map((group) => { + if (group === null) { return null; } return group.map((opt) => { - const [, pattern, flags] = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i); // https://stackoverflow.com/a/33642463 + // https://stackoverflow.com/a/33642463 + const match = opt.pattern.match(/\/(.*?)\/([a-z]*)?$/i); + const [, pattern, flags] = match !== null ? match : ['', '', '']; return {...opt, pattern: new RegExp(pattern, flags ?? '')}; }); }); - return this._translator.findTerms(mode, text, findTermsOptions); + /** @type {import('translation').FindTermsOptions} */ + const modifiedOptions = { + ...options, + enabledDictionaryMap, + excludeDictionaryDefinitions, + textReplacements + }; + return this._translator.findTerms(mode, text, modifiedOptions); } + /** @type {import('offscreen').MessageHandler<'getTermFrequenciesOffscreen', true>} */ _getTermFrequenciesHandler({termReadingList, dictionaries}) { return this._translator.getTermFrequencies(termReadingList, dictionaries); } + /** @type {import('offscreen').MessageHandler<'clearDatabaseCachesOffscreen', false>} */ _clearDatabaseCachesHandler() { - return this._translator.clearDatabaseCaches(); + this._translator.clearDatabaseCaches(); } + /** @type {import('extension').ChromeRuntimeOnMessageCallback} */ _onMessage({action, params}, sender, callback) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } - this._validatePrivilegedMessageSender(sender); - return invokeMessageHandler(messageHandler, params, callback, sender); } - - _validatePrivilegedMessageSender(sender) { - let {url} = sender; - if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } - const {tab} = url; - if (typeof tab === 'object' && tab !== null) { - ({url} = tab); - if (typeof url === 'string' && yomitan.isExtensionUrl(url)) { return; } - } - throw new Error('Invalid message sender'); - } } diff --git a/ext/js/background/profile-conditions-util.js b/ext/js/background/profile-conditions-util.js index 55b287d7..ceade070 100644 --- a/ext/js/background/profile-conditions-util.js +++ b/ext/js/background/profile-conditions-util.js @@ -23,67 +23,55 @@ import {JsonSchema} from '../data/json-schema.js'; */ export class ProfileConditionsUtil { /** - * A group of conditions. - * @typedef {object} ProfileConditionGroup - * @property {ProfileCondition[]} conditions The list of conditions for this group. - */ - - /** - * A single condition. - * @typedef {object} ProfileCondition - * @property {string} type The type of the condition. - * @property {string} operator The condition operator. - * @property {string} value The value to compare against. - */ - - /** * Creates a new instance. */ constructor() { + /** @type {RegExp} */ this._splitPattern = /[,;\s]+/; + /** @type {Map<string, {operators: Map<string, import('profile-conditions-util').CreateSchemaFunction>}>} */ this._descriptors = new Map([ [ 'popupLevel', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['equal', this._createSchemaPopupLevelEqual.bind(this)], ['notEqual', this._createSchemaPopupLevelNotEqual.bind(this)], ['lessThan', this._createSchemaPopupLevelLessThan.bind(this)], ['greaterThan', this._createSchemaPopupLevelGreaterThan.bind(this)], ['lessThanOrEqual', this._createSchemaPopupLevelLessThanOrEqual.bind(this)], ['greaterThanOrEqual', this._createSchemaPopupLevelGreaterThanOrEqual.bind(this)] - ]) + ])) } ], [ 'url', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['matchDomain', this._createSchemaUrlMatchDomain.bind(this)], ['matchRegExp', this._createSchemaUrlMatchRegExp.bind(this)] - ]) + ])) } ], [ 'modifierKeys', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['are', this._createSchemaModifierKeysAre.bind(this)], ['areNot', this._createSchemaModifierKeysAreNot.bind(this)], ['include', this._createSchemaModifierKeysInclude.bind(this)], ['notInclude', this._createSchemaModifierKeysNotInclude.bind(this)] - ]) + ])) } ], [ 'flags', { - operators: new Map([ + operators: new Map(/** @type {import('profile-conditions-util').OperatorMapArray} */ ([ ['are', this._createSchemaFlagsAre.bind(this)], ['areNot', this._createSchemaFlagsAreNot.bind(this)], ['include', this._createSchemaFlagsInclude.bind(this)], ['notInclude', this._createSchemaFlagsNotInclude.bind(this)] - ]) + ])) } ] ]); @@ -91,7 +79,7 @@ export class ProfileConditionsUtil { /** * Creates a new JSON schema descriptor for the given set of condition groups. - * @param {ProfileConditionGroup[]} conditionGroups An array of condition groups. + * @param {import('settings').ProfileConditionGroup[]} conditionGroups An array of condition groups. * For a profile match, all of the items must return successfully in at least one of the groups. * @returns {JsonSchema} A new `JsonSchema` object. */ @@ -127,11 +115,11 @@ export class ProfileConditionsUtil { /** * Creates a normalized version of the context object to test, * assigning dependent fields as needed. - * @param {object} context A context object which is used during schema validation. - * @returns {object} A normalized context object. + * @param {import('settings').OptionsContext} context A context object which is used during schema validation. + * @returns {import('profile-conditions-util').NormalizedOptionsContext} A normalized context object. */ normalizeContext(context) { - const normalizedContext = Object.assign({}, context); + const normalizedContext = /** @type {import('profile-conditions-util').NormalizedOptionsContext} */ (Object.assign({}, context)); const {url} = normalizedContext; if (typeof url === 'string') { try { @@ -149,10 +137,18 @@ export class ProfileConditionsUtil { // Private + /** + * @param {string} value + * @returns {string[]} + */ _split(value) { return value.split(this._splitPattern); } + /** + * @param {string} value + * @returns {number} + */ _stringToNumber(value) { const number = Number.parseFloat(value); return Number.isFinite(number) ? number : 0; @@ -160,64 +156,94 @@ export class ProfileConditionsUtil { // popupLevel schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {const: value} + depth: {const: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelNotEqual(value) { return { - not: [this._createSchemaPopupLevelEqual(value)] + not: { + anyOf: [this._createSchemaPopupLevelEqual(value)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelLessThan(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', exclusiveMaximum: value} + depth: {type: 'number', exclusiveMaximum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelGreaterThan(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', exclusiveMinimum: value} + depth: {type: 'number', exclusiveMinimum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelLessThanOrEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', maximum: value} + depth: {type: 'number', maximum: number} } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaPopupLevelGreaterThanOrEqual(value) { - value = this._stringToNumber(value); + const number = this._stringToNumber(value); return { required: ['depth'], properties: { - depth: {type: 'number', minimum: value} + depth: {type: 'number', minimum: number} } }; } // url schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaUrlMatchDomain(value) { const oneOf = []; for (let domain of this._split(value)) { @@ -233,6 +259,10 @@ export class ProfileConditionsUtil { }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaUrlMatchRegExp(value) { return { required: ['url'], @@ -244,47 +274,91 @@ export class ProfileConditionsUtil { // modifierKeys schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysAre(value) { return this._createSchemaArrayCheck('modifierKeys', value, true, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysAreNot(value) { return { - not: [this._createSchemaArrayCheck('modifierKeys', value, true, false)] + not: { + anyOf: [this._createSchemaArrayCheck('modifierKeys', value, true, false)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysInclude(value) { return this._createSchemaArrayCheck('modifierKeys', value, false, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaModifierKeysNotInclude(value) { return this._createSchemaArrayCheck('modifierKeys', value, false, true); } // modifierKeys schema creation functions + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsAre(value) { return this._createSchemaArrayCheck('flags', value, true, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsAreNot(value) { return { - not: [this._createSchemaArrayCheck('flags', value, true, false)] + not: { + anyOf: [this._createSchemaArrayCheck('flags', value, true, false)] + } }; } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsInclude(value) { return this._createSchemaArrayCheck('flags', value, false, false); } + /** + * @param {string} value + * @returns {import('json-schema').Schema} + */ _createSchemaFlagsNotInclude(value) { return this._createSchemaArrayCheck('flags', value, false, true); } // Generic + /** + * @param {string} key + * @param {string} value + * @param {boolean} exact + * @param {boolean} none + * @returns {import('json-schema').Schema} + */ _createSchemaArrayCheck(key, value, exact, none) { + /** @type {import('json-schema').Schema[]} */ const containsList = []; for (const item of this._split(value)) { if (item.length === 0) { continue; } @@ -295,6 +369,7 @@ export class ProfileConditionsUtil { }); } const containsListCount = containsList.length; + /** @type {import('json-schema').Schema} */ const schema = { type: 'array' }; @@ -303,7 +378,7 @@ export class ProfileConditionsUtil { } if (none) { if (containsListCount > 0) { - schema.not = containsList; + schema.not = {anyOf: containsList}; } } else { schema.minItems = containsListCount; diff --git a/ext/js/background/request-builder.js b/ext/js/background/request-builder.js index f4f685be..23f10ed3 100644 --- a/ext/js/background/request-builder.js +++ b/ext/js/background/request-builder.js @@ -22,16 +22,12 @@ */ export class RequestBuilder { /** - * A progress callback for a fetch read. - * @callback ProgressCallback - * @param {boolean} complete Whether or not the data has been completely read. - */ - - /** * Creates a new instance. */ constructor() { + /** @type {TextEncoder} */ this._textEncoder = new TextEncoder(); + /** @type {Set<number>} */ this._ruleIds = new Set(); } @@ -60,29 +56,32 @@ export class RequestBuilder { this._ruleIds.add(id); try { + /** @type {chrome.declarativeNetRequest.Rule[]} */ const addRules = [{ id, priority: 1, condition: { urlFilter: `|${this._escapeDnrUrl(url)}|`, - resourceTypes: ['xmlhttprequest'] + resourceTypes: [ + /** @type {chrome.declarativeNetRequest.ResourceType} */ ('xmlhttprequest') + ] }, action: { - type: 'modifyHeaders', + type: /** @type {chrome.declarativeNetRequest.RuleActionType} */ ('modifyHeaders'), requestHeaders: [ { - operation: 'remove', + operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'), header: 'Cookie' }, { - operation: 'set', + operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('set'), header: 'Origin', value: originUrl } ], responseHeaders: [ { - operation: 'remove', + operation: /** @type {chrome.declarativeNetRequest.HeaderOperation} */ ('remove'), header: 'Set-Cookie' } ] @@ -103,14 +102,18 @@ export class RequestBuilder { /** * Reads the array buffer body of a fetch response, with an optional `onProgress` callback. * @param {Response} response The response of a `fetch` call. - * @param {ProgressCallback} onProgress The progress callback + * @param {?(done: boolean) => void} onProgress The progress callback. * @returns {Promise<Uint8Array>} The resulting binary data. */ static async readFetchResponseArrayBuffer(response, onProgress) { + /** @type {ReadableStreamDefaultReader<Uint8Array>|undefined} */ let reader; try { - if (typeof onProgress === 'function') { - reader = response.body.getReader(); + if (onProgress !== null) { + const {body} = response; + if (body !== null) { + reader = body.getReader(); + } } } catch (e) { // Not supported @@ -118,15 +121,15 @@ export class RequestBuilder { if (typeof reader === 'undefined') { const result = await response.arrayBuffer(); - if (typeof onProgress === 'function') { + if (onProgress !== null) { onProgress(true); } - return result; + return new Uint8Array(result); } const contentLengthString = response.headers.get('Content-Length'); const contentLength = contentLengthString !== null ? Number.parseInt(contentLengthString, 10) : null; - let target = Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null; + let target = contentLength !== null && Number.isFinite(contentLength) ? new Uint8Array(contentLength) : null; let targetPosition = 0; let totalLength = 0; const targets = []; @@ -134,7 +137,9 @@ export class RequestBuilder { while (true) { const {done, value} = await reader.read(); if (done) { break; } - onProgress(false); + if (onProgress !== null) { + onProgress(false); + } if (target === null) { targets.push({array: value, length: value.length}); } else if (targetPosition + value.length > target.length) { @@ -153,13 +158,16 @@ export class RequestBuilder { target = target.slice(0, totalLength); } - onProgress(true); + if (onProgress !== null) { + onProgress(true); + } - return target; + return /** @type {Uint8Array} */ (target); } // Private + /** */ async _clearSessionRules() { const rules = await this._getSessionRules(); @@ -173,6 +181,9 @@ export class RequestBuilder { await this._updateSessionRules({removeRuleIds}); } + /** + * @returns {Promise<chrome.declarativeNetRequest.Rule[]>} + */ _getSessionRules() { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.getSessionRules((result) => { @@ -186,6 +197,10 @@ export class RequestBuilder { }); } + /** + * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options + * @returns {Promise<void>} + */ _updateSessionRules(options) { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.updateSessionRules(options, () => { @@ -199,6 +214,10 @@ export class RequestBuilder { }); } + /** + * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options + * @returns {Promise<boolean>} + */ async _tryUpdateSessionRules(options) { try { await this._updateSessionRules(options); @@ -208,6 +227,7 @@ export class RequestBuilder { } } + /** */ async _clearDynamicRules() { const rules = await this._getDynamicRules(); @@ -221,6 +241,9 @@ export class RequestBuilder { await this._updateDynamicRules({removeRuleIds}); } + /** + * @returns {Promise<chrome.declarativeNetRequest.Rule[]>} + */ _getDynamicRules() { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.getDynamicRules((result) => { @@ -234,6 +257,10 @@ export class RequestBuilder { }); } + /** + * @param {chrome.declarativeNetRequest.UpdateRuleOptions} options + * @returns {Promise<void>} + */ _updateDynamicRules(options) { return new Promise((resolve, reject) => { chrome.declarativeNetRequest.updateDynamicRules(options, () => { @@ -247,6 +274,10 @@ export class RequestBuilder { }); } + /** + * @returns {number} + * @throws {Error} + */ _getNewRuleId() { let id = 1; while (this._ruleIds.has(id)) { @@ -257,15 +288,27 @@ export class RequestBuilder { return id; } + /** + * @param {string} url + * @returns {string} + */ _getOriginURL(url) { const url2 = new URL(url); return `${url2.protocol}//${url2.host}`; } + /** + * @param {string} url + * @returns {string} + */ _escapeDnrUrl(url) { return url.replace(/[|*^]/g, (char) => this._urlEncodeUtf8(char)); } + /** + * @param {string} text + * @returns {string} + */ _urlEncodeUtf8(text) { const array = this._textEncoder.encode(text); let result = ''; @@ -275,6 +318,11 @@ export class RequestBuilder { return result; } + /** + * @param {{array: Uint8Array, length: number}[]} items + * @param {number} totalLength + * @returns {Uint8Array} + */ static _joinUint8Arrays(items, totalLength) { if (items.length === 1) { const {array, length} = items[0]; diff --git a/ext/js/background/script-manager.js b/ext/js/background/script-manager.js index 3671b854..98f67bb0 100644 --- a/ext/js/background/script-manager.js +++ b/ext/js/background/script-manager.js @@ -17,6 +17,7 @@ */ import {isObject} from '../core.js'; + /** * This class is used to manage script injection into content tabs. */ @@ -25,18 +26,19 @@ export class ScriptManager { * Creates a new instance of the class. */ constructor() { + /** @type {Map<string, ?browser.contentScripts.RegisteredContentScript>} */ this._contentScriptRegistrations = new Map(); } /** * Injects a stylesheet into a tab. - * @param {string} type The type of content to inject; either 'file' or 'code'. + * @param {'file'|'code'} type The type of content to inject; either 'file' or 'code'. * @param {string} content The content to inject. * If type is 'file', this argument should be a path to a file. * If type is 'code', this argument should be the CSS content. * @param {number} tabId The id of the tab to inject into. - * @param {number} [frameId] The id of the frame to inject into. - * @param {boolean} [allFrames] Whether or not the stylesheet should be injected into all frames. + * @param {number|undefined} frameId The id of the frame to inject into. + * @param {boolean} allFrames Whether or not the stylesheet should be injected into all frames. * @returns {Promise<void>} */ injectStylesheet(type, content, tabId, frameId, allFrames) { @@ -51,9 +53,9 @@ export class ScriptManager { * Injects a script into a tab. * @param {string} file The path to a file to inject. * @param {number} tabId The id of the tab to inject into. - * @param {number} [frameId] The id of the frame to inject into. - * @param {boolean} [allFrames] Whether or not the script should be injected into all frames. - * @returns {Promise<{frameId: number, result: object}>} The id of the frame and the result of the script injection. + * @param {number|undefined} frameId The id of the frame to inject into. + * @param {boolean} allFrames Whether or not the script should be injected into all frames. + * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection. */ injectScript(file, tabId, frameId, allFrames) { if (isObject(chrome.scripting) && typeof chrome.scripting.executeScript === 'function') { @@ -98,16 +100,7 @@ export class ScriptManager { * there is a possibility that the script can be injected more than once due to the events used. * Therefore, a reentrant check may need to be performed by the content script. * @param {string} id A unique identifier for the registration. - * @param {object} details The script registration details. - * @param {boolean} [details.allFrames] Same as `all_frames` in the `content_scripts` manifest key. - * @param {string[]} [details.css] List of CSS paths. - * @param {string[]} [details.excludeMatches] Same as `exclude_matches` in the `content_scripts` manifest key. - * @param {string[]} [details.js] List of script paths. - * @param {boolean} [details.matchAboutBlank] Same as `match_about_blank` in the `content_scripts` manifest key. - * @param {string[]} details.matches Same as `matches` in the `content_scripts` manifest key. - * @param {string} [details.urlMatches] Regex match pattern to use as a fallback - * when native content script registration isn't supported. Should be equivalent to `matches`. - * @param {string} [details.runAt] Same as `run_at` in the `content_scripts` manifest key. + * @param {import('script-manager').RegistrationDetails} details The script registration details. * @throws An error is thrown if the id is already in use. */ async registerContentScript(id, details) { @@ -116,8 +109,8 @@ export class ScriptManager { } if (isObject(chrome.scripting) && typeof chrome.scripting.registerContentScripts === 'function') { - const details2 = this._convertContentScriptRegistrationDetails(details, id, false); - await new Promise((resolve, reject) => { + const details2 = this._createContentScriptRegistrationOptionsChrome(details, id); + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.scripting.registerContentScripts([details2], () => { const e = chrome.runtime.lastError; if (e) { @@ -126,7 +119,7 @@ export class ScriptManager { resolve(); } }); - }); + })); this._contentScriptRegistrations.set(id, null); return; } @@ -155,7 +148,7 @@ export class ScriptManager { const registration = this._contentScriptRegistrations.get(id); if (typeof registration === 'undefined') { return false; } this._contentScriptRegistrations.delete(id); - if (isObject(registration) && typeof registration.unregister === 'function') { + if (registration !== null && typeof registration.unregister === 'function') { await registration.unregister(); } return true; @@ -176,17 +169,27 @@ export class ScriptManager { // Private + /** + * @param {'file'|'code'} type + * @param {string} content + * @param {number} tabId + * @param {number|undefined} frameId + * @param {boolean} allFrames + * @returns {Promise<void>} + */ _injectStylesheetMV3(type, content, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { - const details = ( - type === 'file' ? - {origin: 'AUTHOR', files: [content]} : - {origin: 'USER', css: content} - ); - details.target = { + /** @type {chrome.scripting.InjectionTarget} */ + const target = { tabId, allFrames }; + /** @type {chrome.scripting.CSSInjection} */ + const details = ( + type === 'file' ? + {origin: 'AUTHOR', files: [content], target} : + {origin: 'USER', css: content, target} + ); if (!allFrames && typeof frameId === 'number') { details.target.frameIds = [frameId]; } @@ -201,8 +204,16 @@ export class ScriptManager { }); } + /** + * @param {string} file + * @param {number} tabId + * @param {number|undefined} frameId + * @param {boolean} allFrames + * @returns {Promise<{frameId: number|undefined, result: unknown}>} The id of the frame and the result of the script injection. + */ _injectScriptMV3(file, tabId, frameId, allFrames) { return new Promise((resolve, reject) => { + /** @type {chrome.scripting.ScriptInjection<unknown[], unknown>} */ const details = { injectImmediately: true, files: [file], @@ -223,6 +234,10 @@ export class ScriptManager { }); } + /** + * @param {string} id + * @returns {Promise<void>} + */ _unregisterContentScriptMV3(id) { return new Promise((resolve, reject) => { chrome.scripting.unregisterContentScripts({ids: [id]}, () => { @@ -236,73 +251,132 @@ export class ScriptManager { }); } - _convertContentScriptRegistrationDetails(details, id, firefoxConvention) { - const {allFrames, css, excludeMatches, js, matchAboutBlank, matches, runAt} = details; - const details2 = {}; - if (!firefoxConvention) { - details2.id = id; - details2.persistAcrossSessions = true; + /** + * @param {import('script-manager').RegistrationDetails} details + * @returns {browser.contentScripts.RegisteredContentScriptOptions} + */ + _createContentScriptRegistrationOptionsFirefox(details) { + const {css, js, matchAboutBlank} = details; + /** @type {browser.contentScripts.RegisteredContentScriptOptions} */ + const options = {}; + if (typeof matchAboutBlank !== 'undefined') { + options.matchAboutBlank = matchAboutBlank; } + if (Array.isArray(css)) { + options.css = css.map((file) => ({file})); + } + if (Array.isArray(js)) { + options.js = js.map((file) => ({file})); + } + this._initializeContentScriptRegistrationOptionsGeneric(details, options); + return options; + } + + /** + * @param {import('script-manager').RegistrationDetails} details + * @param {string} id + * @returns {chrome.scripting.RegisteredContentScript} + */ + _createContentScriptRegistrationOptionsChrome(details, id) { + const {css, js} = details; + /** @type {chrome.scripting.RegisteredContentScript} */ + const options = { + id: id, + persistAcrossSessions: true + }; + if (Array.isArray(css)) { + options.css = [...css]; + } + if (Array.isArray(js)) { + options.js = [...js]; + } + this._initializeContentScriptRegistrationOptionsGeneric(details, options); + return options; + } + + /** + * @param {import('script-manager').RegistrationDetails} details + * @param {chrome.scripting.RegisteredContentScript|browser.contentScripts.RegisteredContentScriptOptions} options + */ + _initializeContentScriptRegistrationOptionsGeneric(details, options) { + const {allFrames, excludeMatches, matches, runAt} = details; if (typeof allFrames !== 'undefined') { - details2.allFrames = allFrames; + options.allFrames = allFrames; } if (Array.isArray(excludeMatches)) { - details2.excludeMatches = [...excludeMatches]; + options.excludeMatches = [...excludeMatches]; } if (Array.isArray(matches)) { - details2.matches = [...matches]; + options.matches = [...matches]; } if (typeof runAt !== 'undefined') { - details2.runAt = runAt; - } - if (firefoxConvention && typeof matchAboutBlank !== 'undefined') { - details2.matchAboutBlank = matchAboutBlank; + options.runAt = runAt; } - if (Array.isArray(css)) { - details2.css = this._convertFileArray(css, firefoxConvention); - } - if (Array.isArray(js)) { - details2.js = this._convertFileArray(js, firefoxConvention); - } - return details2; } + /** + * @param {string[]} array + * @param {boolean} firefoxConvention + * @returns {string[]|browser.extensionTypes.ExtensionFileOrCode[]} + */ _convertFileArray(array, firefoxConvention) { return firefoxConvention ? array.map((file) => ({file})) : [...array]; } + /** + * @param {string} id + * @param {import('script-manager').RegistrationDetails} details + */ _registerContentScriptFallback(id, details) { const {allFrames, css, js, matchAboutBlank, runAt, urlMatches} = details; - const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: null}; + /** @type {import('script-manager').ContentScriptInjectionDetails} */ + const details2 = {allFrames, css, js, matchAboutBlank, runAt, urlRegex: /** @type {?RegExp} */ (null)}; + /** @type {() => Promise<void>} */ let unregister; const webNavigationEvent = this._getWebNavigationEvent(runAt); - if (isObject(webNavigationEvent)) { + if (typeof webNavigationEvent === 'object' && webNavigationEvent !== null) { + /** + * @param {chrome.webNavigation.WebNavigationFramedCallbackDetails} details + */ const onTabCommitted = ({url, tabId, frameId}) => { this._injectContentScript(true, details2, null, url, tabId, frameId); }; const filter = {url: [{urlMatches}]}; webNavigationEvent.addListener(onTabCommitted, filter); - unregister = () => webNavigationEvent.removeListener(onTabCommitted); + unregister = async () => webNavigationEvent.removeListener(onTabCommitted); } else { + /** + * @param {number} tabId + * @param {chrome.tabs.TabChangeInfo} changeInfo + * @param {chrome.tabs.Tab} tab + */ const onTabUpdated = (tabId, {status}, {url}) => { if (typeof status === 'string' && typeof url === 'string') { this._injectContentScript(false, details2, status, url, tabId, void 0); } }; - const extraParameters = {url: [urlMatches], properties: ['status']}; try { // Firefox - chrome.tabs.onUpdated.addListener(onTabUpdated, extraParameters); + /** @type {browser.tabs.UpdateFilter} */ + const extraParameters = {urls: [urlMatches], properties: ['status']}; + browser.tabs.onUpdated.addListener( + /** @type {(tabId: number, changeInfo: browser.tabs._OnUpdatedChangeInfo, tab: browser.tabs.Tab) => void} */ (onTabUpdated), + extraParameters + ); } catch (e) { // Chrome details2.urlRegex = new RegExp(urlMatches); chrome.tabs.onUpdated.addListener(onTabUpdated); } - unregister = () => chrome.tabs.onUpdated.removeListener(onTabUpdated); + unregister = async () => chrome.tabs.onUpdated.removeListener(onTabUpdated); } this._contentScriptRegistrations.set(id, {unregister}); } + /** + * @param {import('script-manager').RunAt} runAt + * @returns {?(chrome.webNavigation.WebNavigationFramedEvent|chrome.webNavigation.WebNavigationTransitionalEvent)} + */ _getWebNavigationEvent(runAt) { const {webNavigation} = chrome; if (!isObject(webNavigation)) { return null; } @@ -316,6 +390,14 @@ export class ScriptManager { } } + /** + * @param {boolean} isWebNavigation + * @param {import('script-manager').ContentScriptInjectionDetails} details + * @param {?string} status + * @param {string} url + * @param {number} tabId + * @param {number|undefined} frameId + */ async _injectContentScript(isWebNavigation, details, status, url, tabId, frameId) { const {urlRegex} = details; if (urlRegex !== null && !urlRegex.test(url)) { return; } diff --git a/ext/js/comm/anki-connect.js b/ext/js/comm/anki-connect.js index 09838ea5..7ff8d0e1 100644 --- a/ext/js/comm/anki-connect.js +++ b/ext/js/comm/anki-connect.js @@ -16,7 +16,7 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {AnkiUtil} from '../data/anki-util.js'; /** @@ -27,17 +27,23 @@ export class AnkiConnect { * Creates a new instance. */ constructor() { + /** @type {boolean} */ this._enabled = false; + /** @type {?string} */ this._server = null; + /** @type {number} */ this._localVersion = 2; + /** @type {number} */ this._remoteVersion = 0; + /** @type {?Promise<number>} */ this._versionCheckPromise = null; + /** @type {?string} */ this._apiKey = null; } /** * Gets the URL of the AnkiConnect server. - * @type {string} + * @type {?string} */ get server() { return this._server; @@ -90,7 +96,7 @@ export class AnkiConnect { */ async isConnected() { try { - await this._invoke('version'); + await this._getVersion(); return true; } catch (e) { return false; @@ -99,74 +105,114 @@ export class AnkiConnect { /** * Gets the AnkiConnect API version number. - * @returns {Promise<number>} The version number + * @returns {Promise<?number>} The version number */ async getVersion() { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('version', {}); + return await this._getVersion(); } + /** + * @param {import('anki').Note} note + * @returns {Promise<?import('anki').NoteId>} + */ async addNote(note) { if (!this._enabled) { return null; } await this._checkVersion(); - return await this._invoke('addNote', {note}); + const result = await this._invoke('addNote', {note}); + if (result !== null && typeof result !== 'number') { + throw this._createUnexpectedResultError('number|null', result); + } + return result; } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<boolean[]>} + */ async canAddNotes(notes) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('canAddNotes', {notes}); + const result = await this._invoke('canAddNotes', {notes}); + return this._normalizeArray(result, notes.length, 'boolean'); } - async notesInfo(notes) { + /** + * @param {import('anki').NoteId[]} noteIds + * @returns {Promise<(?import('anki').NoteInfo)[]>} + */ + async notesInfo(noteIds) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('notesInfo', {notes}); + const result = await this._invoke('notesInfo', {notes: noteIds}); + return this._normalizeNoteInfoArray(result); } + /** + * @returns {Promise<string[]>} + */ async getDeckNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('deckNames'); + const result = await this._invoke('deckNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @returns {Promise<string[]>} + */ async getModelNames() { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelNames'); + const result = await this._invoke('modelNames', {}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} modelName + * @returns {Promise<string[]>} + */ async getModelFieldNames(modelName) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('modelFieldNames', {modelName}); + const result = await this._invoke('modelFieldNames', {modelName}); + return this._normalizeArray(result, -1, 'string'); } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowse(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('guiBrowse', {query}); + const result = await this._invoke('guiBrowse', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async guiBrowseNote(noteId) { return await this.guiBrowse(`nid:${noteId}`); } /** * Opens the note editor GUI. - * @param {number} noteId The ID of the note. - * @returns {Promise<null>} Nothing is returned. + * @param {import('anki').NoteId} noteId The ID of the note. + * @returns {Promise<void>} Nothing is returned. */ async guiEditNote(noteId) { - return await this._invoke('guiEditNote', {note: noteId}); + await this._invoke('guiEditNote', {note: noteId}); } /** * Stores a file with the specified base64-encoded content inside Anki's media folder. * @param {string} fileName The name of the file. * @param {string} content The base64-encoded content of the file. - * @returns {?string} The actual file name used to store the file, which may be different; or `null` if the file was not stored. + * @returns {Promise<?string>} The actual file name used to store the file, which may be different; or `null` if the file was not stored. * @throws {Error} An error is thrown is this object is not enabled. */ async storeMediaFile(fileName, content) { @@ -174,28 +220,39 @@ export class AnkiConnect { throw new Error('AnkiConnect not enabled'); } await this._checkVersion(); - return await this._invoke('storeMediaFile', {filename: fileName, data: content}); + const result = await this._invoke('storeMediaFile', {filename: fileName, data: content}); + if (result !== null && typeof result !== 'string') { + throw this._createUnexpectedResultError('string|null', result); + } + return result; } /** * Finds notes matching a query. * @param {string} query Searches for notes matching a query. - * @returns {number[]} An array of note IDs. + * @returns {Promise<import('anki').NoteId[]>} An array of note IDs. * @see https://docs.ankiweb.net/searching.html */ async findNotes(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findNotes', {query}); + const result = await this._invoke('findNotes', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').Note[]} notes + * @returns {Promise<import('anki').NoteId[][]>} + */ async findNoteIds(notes) { if (!this._enabled) { return []; } await this._checkVersion(); const actions = []; const actionsTargetsList = []; + /** @type {Map<string, import('anki').NoteId[][]>} */ const actionsTargetsMap = new Map(); + /** @type {import('anki').NoteId[][]} */ const allNoteIds = []; for (const note of notes) { @@ -207,14 +264,15 @@ export class AnkiConnect { actionsTargetsMap.set(query, actionsTargets); actions.push({action: 'findNotes', params: {query}}); } + /** @type {import('anki').NoteId[]} */ const noteIds = []; allNoteIds.push(noteIds); actionsTargets.push(noteIds); } - const result = await this._invoke('multi', {actions}); + const result = await this._invokeMulti(actions); for (let i = 0, ii = Math.min(result.length, actionsTargetsList.length); i < ii; ++i) { - const noteIds = result[i]; + const noteIds = /** @type {number[]} */ (this._normalizeArray(result[i], -1, 'number')); for (const actionsTargets of actionsTargetsList[i]) { for (const noteId of noteIds) { actionsTargets.push(noteId); @@ -224,18 +282,32 @@ export class AnkiConnect { return allNoteIds; } + /** + * @param {import('anki').CardId[]} cardIds + * @returns {Promise<boolean>} + */ async suspendCards(cardIds) { if (!this._enabled) { return false; } await this._checkVersion(); - return await this._invoke('suspend', {cards: cardIds}); + const result = await this._invoke('suspend', {cards: cardIds}); + return typeof result === 'boolean' && result; } + /** + * @param {string} query + * @returns {Promise<import('anki').CardId[]>} + */ async findCards(query) { if (!this._enabled) { return []; } await this._checkVersion(); - return await this._invoke('findCards', {query}); + const result = await this._invoke('findCards', {query}); + return this._normalizeArray(result, -1, 'number'); } + /** + * @param {import('anki').NoteId} noteId + * @returns {Promise<import('anki').CardId[]>} + */ async findCardsForNote(noteId) { return await this.findCards(`nid:${noteId}`); } @@ -244,16 +316,26 @@ export class AnkiConnect { * Gets information about the AnkiConnect APIs available. * @param {string[]} scopes A list of scopes to get information about. * @param {?string[]} actions A list of actions to check for - * @returns {object} Information about the APIs. + * @returns {Promise<import('anki').ApiReflectResult>} Information about the APIs. */ async apiReflect(scopes, actions=null) { - return await this._invoke('apiReflect', {scopes, actions}); + const result = await this._invoke('apiReflect', {scopes, actions}); + if (!(typeof result === 'object' && result !== null)) { + throw this._createUnexpectedResultError('object', result); + } + const {scopes: resultScopes, actions: resultActions} = /** @type {import('core').SerializableObject} */ (result); + const resultScopes2 = /** @type {string[]} */ (this._normalizeArray(resultScopes, -1, 'string', ', field scopes')); + const resultActions2 = /** @type {string[]} */ (this._normalizeArray(resultActions, -1, 'string', ', field scopes')); + return { + scopes: resultScopes2, + actions: resultActions2 + }; } /** * Checks whether a specific API action exists. * @param {string} action The action to check for. - * @returns {boolean} Whether or not the action exists. + * @returns {Promise<boolean>} Whether or not the action exists. */ async apiExists(action) { const {actions} = await this.apiReflect(['actions'], [action]); @@ -266,9 +348,9 @@ export class AnkiConnect { * @returns {boolean} Whether or not the error indicates the action is not supported. */ isErrorUnsupportedAction(error) { - if (error instanceof Error) { + if (error instanceof ExtensionError) { const {data} = error; - if (isObject(data) && data.apiError === 'unsupported action') { + if (typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).apiError === 'unsupported action') { return true; } } @@ -277,10 +359,13 @@ export class AnkiConnect { // Private + /** + * @returns {Promise<void>} + */ async _checkVersion() { if (this._remoteVersion < this._localVersion) { if (this._versionCheckPromise === null) { - const promise = this._invoke('version'); + const promise = this._getVersion(); promise .catch(() => {}) .finally(() => { this._versionCheckPromise = null; }); @@ -293,11 +378,18 @@ export class AnkiConnect { } } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<unknown>} + */ async _invoke(action, params) { + /** @type {import('anki').MessageBody} */ const body = {action, params, version: this._localVersion}; if (this._apiKey !== null) { body.key = this._apiKey; } let response; try { + if (this._server === null) { throw new Error('Server URL is null'); } response = await fetch(this._server, { method: 'POST', mode: 'cors', @@ -311,33 +403,34 @@ export class AnkiConnect { body: JSON.stringify(body) }); } catch (e) { - const error = new Error('Anki connection failure'); + const error = new ExtensionError('Anki connection failure'); error.data = {action, params, originalError: e}; throw error; } if (!response.ok) { - const error = new Error(`Anki connection error: ${response.status}`); + const error = new ExtensionError(`Anki connection error: ${response.status}`); error.data = {action, params, status: response.status}; throw error; } let responseText = null; + /** @type {unknown} */ let result; try { responseText = await response.text(); result = JSON.parse(responseText); } catch (e) { - const error = new Error('Invalid Anki response'); + const error = new ExtensionError('Invalid Anki response'); error.data = {action, params, status: response.status, responseText, originalError: e}; throw error; } - if (isObject(result)) { - const apiError = result.error; + if (typeof result === 'object' && result !== null && !Array.isArray(result)) { + const apiError = /** @type {import('core').SerializableObject} */ (result).error; if (typeof apiError !== 'undefined') { - const error = new Error(`Anki error: ${apiError}`); - error.data = {action, params, status: response.status, apiError}; + const error = new ExtensionError(`Anki error: ${apiError}`); + error.data = {action, params, status: response.status, apiError: typeof apiError === 'string' ? apiError : `${apiError}`}; throw error; } } @@ -345,10 +438,30 @@ export class AnkiConnect { return result; } + /** + * @param {{action: string, params: import('core').SerializableObject}[]} actions + * @returns {Promise<unknown[]>} + */ + async _invokeMulti(actions) { + const result = await this._invoke('multi', {actions}); + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result); + } + return result; + } + + /** + * @param {string} text + * @returns {string} + */ _escapeQuery(text) { return text.replace(/"/g, ''); } + /** + * @param {import('anki').NoteFields} fields + * @returns {string} + */ _fieldsToQuery(fields) { const fieldNames = Object.keys(fields); if (fieldNames.length === 0) { @@ -359,6 +472,10 @@ export class AnkiConnect { return `"${key.toLowerCase()}:${this._escapeQuery(fields[key])}"`; } + /** + * @param {import('anki').Note} note + * @returns {?('collection'|'deck'|'deck-root')} + */ _getDuplicateScopeFromNote(note) { const {options} = note; if (typeof options === 'object' && options !== null) { @@ -370,6 +487,10 @@ export class AnkiConnect { return null; } + /** + * @param {import('anki').Note} note + * @returns {string} + */ _getNoteQuery(note) { let query = ''; switch (this._getDuplicateScopeFromNote(note)) { @@ -383,4 +504,121 @@ export class AnkiConnect { query += this._fieldsToQuery(note.fields); return query; } + + /** + * @returns {Promise<number>} + */ + async _getVersion() { + const version = await this._invoke('version', {}); + return typeof version === 'number' ? version : 0; + } + + /** + * @param {string} message + * @param {unknown} data + * @returns {ExtensionError} + */ + _createError(message, data) { + const error = new ExtensionError(message); + error.data = data; + return error; + } + + /** + * @param {string} expectedType + * @param {unknown} result + * @param {string} [context] + * @returns {ExtensionError} + */ + _createUnexpectedResultError(expectedType, result, context) { + return this._createError(`Unexpected type${typeof context === 'string' ? context : ''}: expected ${expectedType}, received ${this._getTypeName(result)}`, result); + } + + /** + * @param {unknown} value + * @returns {string} + */ + _getTypeName(value) { + if (value === null) { return 'null'; } + return Array.isArray(value) ? 'array' : typeof value; + } + + /** + * @template [T=unknown] + * @param {unknown} result + * @param {number} expectedCount + * @param {'boolean'|'string'|'number'} type + * @param {string} [context] + * @returns {T[]} + * @throws {Error} + */ + _normalizeArray(result, expectedCount, type, context) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError(`${type}[]`, result, context); + } + if (expectedCount < 0) { + expectedCount = result.length; + } else if (expectedCount !== result.length) { + throw this._createError(`Unexpected result array size${context}: expected ${expectedCount}, received ${result.length}`, result); + } + for (let i = 0; i < expectedCount; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (typeof item !== type) { + throw this._createError(`Unexpected result type at index ${i}${context}: expected ${type}, received ${this._getTypeName(item)}`, result); + } + } + return /** @type {T[]} */ (result); + } + + /** + * @param {unknown} result + * @returns {(?import('anki').NoteInfo)[]} + * @throws {Error} + */ + _normalizeNoteInfoArray(result) { + if (!Array.isArray(result)) { + throw this._createUnexpectedResultError('array', result, ''); + } + /** @type {(?import('anki').NoteInfo)[]} */ + const result2 = []; + for (let i = 0, ii = result.length; i < ii; ++i) { + const item = /** @type {unknown} */ (result[i]); + if (item === null || typeof item !== 'object') { + throw this._createError(`Unexpected result type at index ${i}: expected Notes.NoteInfo, received ${this._getTypeName(item)}`, result); + } + const {noteId} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof noteId !== 'number') { + result2.push(null); + continue; + } + + const {tags, fields, modelName, cards} = /** @type {{[key: string]: unknown}} */ (item); + if (typeof modelName !== 'string') { + throw this._createError(`Unexpected result type at index ${i}, field modelName: expected string, received ${this._getTypeName(modelName)}`, result); + } + if (typeof fields !== 'object' || fields === null) { + throw this._createError(`Unexpected result type at index ${i}, field fields: expected string, received ${this._getTypeName(fields)}`, result); + } + const tags2 = /** @type {string[]} */ (this._normalizeArray(tags, -1, 'string', ', field tags')); + const cards2 = /** @type {number[]} */ (this._normalizeArray(cards, -1, 'number', ', field cards')); + /** @type {{[key: string]: import('anki').NoteFieldInfo}} */ + const fields2 = {}; + for (const [key, fieldInfo] of Object.entries(fields)) { + if (typeof fieldInfo !== 'object' || fieldInfo === null) { continue; } + const {value, order} = fieldInfo; + if (typeof value !== 'string' || typeof order !== 'number') { continue; } + fields2[key] = {value, order}; + } + /** @type {import('anki').NoteInfo} */ + const item2 = { + noteId, + tags: tags2, + fields: fields2, + modelName, + cards: cards2 + }; + result2.push(item2); + } + return result2; + } } diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js index 05f95464..26218595 100644 --- a/ext/js/comm/api.js +++ b/ext/js/comm/api.js @@ -16,184 +16,428 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deferPromise, deserializeError, isObject} from '../core.js'; +import {deferPromise} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; export class API { + /** + * @param {import('../yomitan.js').Yomitan} yomitan + */ constructor(yomitan) { + /** @type {import('../yomitan.js').Yomitan} */ this._yomitan = yomitan; } + /** + * @param {import('api').OptionsGetDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').OptionsGetResult>} + */ optionsGet(optionsContext) { - return this._invoke('optionsGet', {optionsContext}); + /** @type {import('api').OptionsGetDetails} */ + const details = {optionsContext}; + return this._invoke('optionsGet', details); } + /** + * @returns {Promise<import('api').OptionsGetFullResult>} + */ optionsGetFull() { return this._invoke('optionsGetFull'); } + /** + * @param {import('api').TermsFindDetails['text']} text + * @param {import('api').TermsFindDetails['details']} details + * @param {import('api').TermsFindDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').TermsFindResult>} + */ termsFind(text, details, optionsContext) { - return this._invoke('termsFind', {text, details, optionsContext}); - } - + /** @type {import('api').TermsFindDetails} */ + const details2 = {text, details, optionsContext}; + return this._invoke('termsFind', details2); + } + + /** + * @param {import('api').ParseTextDetails['text']} text + * @param {import('api').ParseTextDetails['optionsContext']} optionsContext + * @param {import('api').ParseTextDetails['scanLength']} scanLength + * @param {import('api').ParseTextDetails['useInternalParser']} useInternalParser + * @param {import('api').ParseTextDetails['useMecabParser']} useMecabParser + * @returns {Promise<import('api').ParseTextResult>} + */ parseText(text, optionsContext, scanLength, useInternalParser, useMecabParser) { - return this._invoke('parseText', {text, optionsContext, scanLength, useInternalParser, useMecabParser}); + /** @type {import('api').ParseTextDetails} */ + const details = {text, optionsContext, scanLength, useInternalParser, useMecabParser}; + return this._invoke('parseText', details); } + /** + * @param {import('api').KanjiFindDetails['text']} text + * @param {import('api').KanjiFindDetails['optionsContext']} optionsContext + * @returns {Promise<import('api').KanjiFindResult>} + */ kanjiFind(text, optionsContext) { - return this._invoke('kanjiFind', {text, optionsContext}); + /** @type {import('api').KanjiFindDetails} */ + const details = {text, optionsContext}; + return this._invoke('kanjiFind', details); } + /** + * @returns {Promise<import('api').IsAnkiConnectedResult>} + */ isAnkiConnected() { return this._invoke('isAnkiConnected'); } + /** + * @returns {Promise<import('api').GetAnkiConnectVersionResult>} + */ getAnkiConnectVersion() { return this._invoke('getAnkiConnectVersion'); } + /** + * @param {import('api').AddAnkiNoteDetails['note']} note + * @returns {Promise<import('api').AddAnkiNoteResult>} + */ addAnkiNote(note) { - return this._invoke('addAnkiNote', {note}); + /** @type {import('api').AddAnkiNoteDetails} */ + const details = {note}; + return this._invoke('addAnkiNote', details); } + /** + * @param {import('api').GetAnkiNoteInfoDetails['notes']} notes + * @param {import('api').GetAnkiNoteInfoDetails['fetchAdditionalInfo']} fetchAdditionalInfo + * @returns {Promise<import('api').GetAnkiNoteInfoResult>} + */ getAnkiNoteInfo(notes, fetchAdditionalInfo) { - return this._invoke('getAnkiNoteInfo', {notes, fetchAdditionalInfo}); - } - + /** @type {import('api').GetAnkiNoteInfoDetails} */ + const details = {notes, fetchAdditionalInfo}; + return this._invoke('getAnkiNoteInfo', details); + } + + /** + * @param {import('api').InjectAnkiNoteMediaDetails['timestamp']} timestamp + * @param {import('api').InjectAnkiNoteMediaDetails['definitionDetails']} definitionDetails + * @param {import('api').InjectAnkiNoteMediaDetails['audioDetails']} audioDetails + * @param {import('api').InjectAnkiNoteMediaDetails['screenshotDetails']} screenshotDetails + * @param {import('api').InjectAnkiNoteMediaDetails['clipboardDetails']} clipboardDetails + * @param {import('api').InjectAnkiNoteMediaDetails['dictionaryMediaDetails']} dictionaryMediaDetails + * @returns {Promise<import('api').InjectAnkiNoteMediaResult>} + */ injectAnkiNoteMedia(timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails) { - return this._invoke('injectAnkiNoteMedia', {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}); + /** @type {import('api').InjectAnkiNoteMediaDetails} */ + const details = {timestamp, definitionDetails, audioDetails, screenshotDetails, clipboardDetails, dictionaryMediaDetails}; + return this._invoke('injectAnkiNoteMedia', details); } + /** + * @param {import('api').NoteViewDetails['noteId']} noteId + * @param {import('api').NoteViewDetails['mode']} mode + * @param {import('api').NoteViewDetails['allowFallback']} allowFallback + * @returns {Promise<import('api').NoteViewResult>} + */ noteView(noteId, mode, allowFallback) { - return this._invoke('noteView', {noteId, mode, allowFallback}); + /** @type {import('api').NoteViewDetails} */ + const details = {noteId, mode, allowFallback}; + return this._invoke('noteView', details); } + /** + * @param {import('api').SuspendAnkiCardsForNoteDetails['noteId']} noteId + * @returns {Promise<import('api').SuspendAnkiCardsForNoteResult>} + */ suspendAnkiCardsForNote(noteId) { - return this._invoke('suspendAnkiCardsForNote', {noteId}); + /** @type {import('api').SuspendAnkiCardsForNoteDetails} */ + const details = {noteId}; + return this._invoke('suspendAnkiCardsForNote', details); } + /** + * @param {import('api').GetTermAudioInfoListDetails['source']} source + * @param {import('api').GetTermAudioInfoListDetails['term']} term + * @param {import('api').GetTermAudioInfoListDetails['reading']} reading + * @returns {Promise<import('api').GetTermAudioInfoListResult>} + */ getTermAudioInfoList(source, term, reading) { - return this._invoke('getTermAudioInfoList', {source, term, reading}); + /** @type {import('api').GetTermAudioInfoListDetails} */ + const details = {source, term, reading}; + return this._invoke('getTermAudioInfoList', details); } + /** + * @param {import('api').CommandExecDetails['command']} command + * @param {import('api').CommandExecDetails['params']} [params] + * @returns {Promise<import('api').CommandExecResult>} + */ commandExec(command, params) { - return this._invoke('commandExec', {command, params}); + /** @type {import('api').CommandExecDetails} */ + const details = {command, params}; + return this._invoke('commandExec', details); } + /** + * @param {import('api').SendMessageToFrameDetails['frameId']} frameId + * @param {import('api').SendMessageToFrameDetails['action']} action + * @param {import('api').SendMessageToFrameDetails['params']} [params] + * @returns {Promise<import('api').SendMessageToFrameResult>} + */ sendMessageToFrame(frameId, action, params) { - return this._invoke('sendMessageToFrame', {frameId, action, params}); + /** @type {import('api').SendMessageToFrameDetails} */ + const details = {frameId, action, params}; + return this._invoke('sendMessageToFrame', details); } + /** + * @param {import('api').BroadcastTabDetails['action']} action + * @param {import('api').BroadcastTabDetails['params']} params + * @returns {Promise<import('api').BroadcastTabResult>} + */ broadcastTab(action, params) { - return this._invoke('broadcastTab', {action, params}); + /** @type {import('api').BroadcastTabDetails} */ + const details = {action, params}; + return this._invoke('broadcastTab', details); } + /** + * @returns {Promise<import('api').FrameInformationGetResult>} + */ frameInformationGet() { return this._invoke('frameInformationGet'); } + /** + * @param {import('api').InjectStylesheetDetails['type']} type + * @param {import('api').InjectStylesheetDetails['value']} value + * @returns {Promise<import('api').InjectStylesheetResult>} + */ injectStylesheet(type, value) { - return this._invoke('injectStylesheet', {type, value}); + /** @type {import('api').InjectStylesheetDetails} */ + const details = {type, value}; + return this._invoke('injectStylesheet', details); } + /** + * @param {import('api').GetStylesheetContentDetails['url']} url + * @returns {Promise<import('api').GetStylesheetContentResult>} + */ getStylesheetContent(url) { - return this._invoke('getStylesheetContent', {url}); + /** @type {import('api').GetStylesheetContentDetails} */ + const details = {url}; + return this._invoke('getStylesheetContent', details); } + /** + * @returns {Promise<import('api').GetEnvironmentInfoResult>} + */ getEnvironmentInfo() { return this._invoke('getEnvironmentInfo'); } + /** + * @returns {Promise<import('api').ClipboardGetResult>} + */ clipboardGet() { return this._invoke('clipboardGet'); } + /** + * @returns {Promise<import('api').GetDisplayTemplatesHtmlResult>} + */ getDisplayTemplatesHtml() { return this._invoke('getDisplayTemplatesHtml'); } + /** + * @returns {Promise<import('api').GetZoomResult>} + */ getZoom() { return this._invoke('getZoom'); } + /** + * @returns {Promise<import('api').GetDefaultAnkiFieldTemplatesResult>} + */ getDefaultAnkiFieldTemplates() { return this._invoke('getDefaultAnkiFieldTemplates'); } + /** + * @returns {Promise<import('api').GetDictionaryInfoResult>} + */ getDictionaryInfo() { return this._invoke('getDictionaryInfo'); } + /** + * @returns {Promise<import('api').PurgeDatabaseResult>} + */ purgeDatabase() { return this._invoke('purgeDatabase'); } + /** + * @param {import('api').GetMediaDetails['targets']} targets + * @returns {Promise<import('api').GetMediaResult>} + */ getMedia(targets) { - return this._invoke('getMedia', {targets}); + /** @type {import('api').GetMediaDetails} */ + const details = {targets}; + return this._invoke('getMedia', details); } + /** + * @param {import('api').LogDetails['error']} error + * @param {import('api').LogDetails['level']} level + * @param {import('api').LogDetails['context']} context + * @returns {Promise<import('api').LogResult>} + */ log(error, level, context) { - return this._invoke('log', {error, level, context}); + /** @type {import('api').LogDetails} */ + const details = {error, level, context}; + return this._invoke('log', details); } + /** + * @returns {Promise<import('api').LogIndicatorClearResult>} + */ logIndicatorClear() { return this._invoke('logIndicatorClear'); } + /** + * @param {import('api').ModifySettingsDetails['targets']} targets + * @param {import('api').ModifySettingsDetails['source']} source + * @returns {Promise<import('api').ModifySettingsResult>} + */ modifySettings(targets, source) { - return this._invoke('modifySettings', {targets, source}); + const details = {targets, source}; + return this._invoke('modifySettings', details); } + /** + * @param {import('api').GetSettingsDetails['targets']} targets + * @returns {Promise<import('api').GetSettingsResult>} + */ getSettings(targets) { - return this._invoke('getSettings', {targets}); + /** @type {import('api').GetSettingsDetails} */ + const details = {targets}; + return this._invoke('getSettings', details); } + /** + * @param {import('api').SetAllSettingsDetails['value']} value + * @param {import('api').SetAllSettingsDetails['source']} source + * @returns {Promise<import('api').SetAllSettingsResult>} + */ setAllSettings(value, source) { - return this._invoke('setAllSettings', {value, source}); + /** @type {import('api').SetAllSettingsDetails} */ + const details = {value, source}; + return this._invoke('setAllSettings', details); } + /** + * @param {import('api').GetOrCreateSearchPopupDetails} details + * @returns {Promise<import('api').GetOrCreateSearchPopupResult>} + */ getOrCreateSearchPopup(details) { - return this._invoke('getOrCreateSearchPopup', isObject(details) ? details : {}); + return this._invoke('getOrCreateSearchPopup', details); } + /** + * @param {import('api').IsTabSearchPopupDetails['tabId']} tabId + * @returns {Promise<import('api').IsTabSearchPopupResult>} + */ isTabSearchPopup(tabId) { - return this._invoke('isTabSearchPopup', {tabId}); + /** @type {import('api').IsTabSearchPopupDetails} */ + const details = {tabId}; + return this._invoke('isTabSearchPopup', details); } + /** + * @param {import('api').TriggerDatabaseUpdatedDetails['type']} type + * @param {import('api').TriggerDatabaseUpdatedDetails['cause']} cause + * @returns {Promise<import('api').TriggerDatabaseUpdatedResult>} + */ triggerDatabaseUpdated(type, cause) { - return this._invoke('triggerDatabaseUpdated', {type, cause}); + /** @type {import('api').TriggerDatabaseUpdatedDetails} */ + const details = {type, cause}; + return this._invoke('triggerDatabaseUpdated', details); } + /** + * @returns {Promise<import('api').TestMecabResult>} + */ testMecab() { - return this._invoke('testMecab', {}); + return this._invoke('testMecab'); } + /** + * @param {import('api').TextHasJapaneseCharactersDetails['text']} text + * @returns {Promise<import('api').TextHasJapaneseCharactersResult>} + */ textHasJapaneseCharacters(text) { - return this._invoke('textHasJapaneseCharacters', {text}); + /** @type {import('api').TextHasJapaneseCharactersDetails} */ + const details = {text}; + return this._invoke('textHasJapaneseCharacters', details); } + /** + * @param {import('api').GetTermFrequenciesDetails['termReadingList']} termReadingList + * @param {import('api').GetTermFrequenciesDetails['dictionaries']} dictionaries + * @returns {Promise<import('api').GetTermFrequenciesResult>} + */ getTermFrequencies(termReadingList, dictionaries) { - return this._invoke('getTermFrequencies', {termReadingList, dictionaries}); + /** @type {import('api').GetTermFrequenciesDetails} */ + const details = {termReadingList, dictionaries}; + return this._invoke('getTermFrequencies', details); } + /** + * @param {import('api').FindAnkiNotesDetails['query']} query + * @returns {Promise<import('api').FindAnkiNotesResult>} + */ findAnkiNotes(query) { - return this._invoke('findAnkiNotes', {query}); + /** @type {import('api').FindAnkiNotesDetails} */ + const details = {query}; + return this._invoke('findAnkiNotes', details); } + /** + * @param {import('api').LoadExtensionScriptsDetails['files']} files + * @returns {Promise<import('api').LoadExtensionScriptsResult>} + */ loadExtensionScripts(files) { - return this._invoke('loadExtensionScripts', {files}); + /** @type {import('api').LoadExtensionScriptsDetails} */ + const details = {files}; + return this._invoke('loadExtensionScripts', details); } + /** + * @param {import('api').OpenCrossFramePortDetails['targetTabId']} targetTabId + * @param {import('api').OpenCrossFramePortDetails['targetFrameId']} targetFrameId + * @returns {Promise<import('api').OpenCrossFramePortResult>} + */ openCrossFramePort(targetTabId, targetFrameId) { return this._invoke('openCrossFramePort', {targetTabId, targetFrameId}); } // Utilities - _createActionPort(timeout=5000) { + /** + * @param {number} timeout + * @returns {Promise<chrome.runtime.Port>} + */ + _createActionPort(timeout) { return new Promise((resolve, reject) => { + /** @type {?import('core').Timeout} */ let timer = null; const portDetails = deferPromise(); + /** + * @param {chrome.runtime.Port} port + */ const onConnect = async (port) => { try { const {name: expectedName, id: expectedId} = await portDetails.promise; @@ -210,6 +454,9 @@ export class API { resolve(port); }; + /** + * @param {Error} e + */ const onError = (e) => { if (timer !== null) { clearTimeout(timer); @@ -227,14 +474,24 @@ export class API { }); } - _invokeWithProgress(action, params, onProgress, timeout=5000) { + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} params + * @param {?(...args: unknown[]) => void} onProgress0 + * @param {number} [timeout] + * @returns {Promise<TReturn>} + */ + _invokeWithProgress(action, params, onProgress0, timeout=5000) { return new Promise((resolve, reject) => { + /** @type {?chrome.runtime.Port} */ let port = null; - if (typeof onProgress !== 'function') { - onProgress = () => {}; - } + const onProgress = typeof onProgress0 === 'function' ? onProgress0 : () => {}; + /** + * @param {import('backend').InvokeWithProgressResponseMessage<TReturn>} message + */ const onMessage = (message) => { switch (message.type) { case 'progress': @@ -250,7 +507,7 @@ export class API { break; case 'error': cleanup(); - reject(deserializeError(message.data)); + reject(ExtensionError.deserialize(message.data)); break; } }; @@ -267,7 +524,6 @@ export class API { port.disconnect(); port = null; } - onProgress = null; }; (async () => { @@ -281,20 +537,23 @@ export class API { const fragmentSize = 1e7; // 10 MB for (let i = 0, ii = messageString.length; i < ii; i += fragmentSize) { const data = messageString.substring(i, i + fragmentSize); - port.postMessage({action: 'fragment', data}); + port.postMessage(/** @type {import('backend').InvokeWithProgressRequestFragmentMessage} */ ({action: 'fragment', data})); } - port.postMessage({action: 'invoke'}); + port.postMessage(/** @type {import('backend').InvokeWithProgressRequestInvokeMessage} */ ({action: 'invoke'})); } catch (e) { cleanup(); reject(e); - } finally { - action = null; - params = null; } })(); }); } + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} [params] + * @returns {Promise<TReturn>} + */ _invoke(action, params={}) { const data = {action, params}; return new Promise((resolve, reject) => { @@ -303,7 +562,7 @@ export class API { this._checkLastError(chrome.runtime.lastError); if (response !== null && typeof response === 'object') { if (typeof response.error !== 'undefined') { - reject(deserializeError(response.error)); + reject(ExtensionError.deserialize(response.error)); } else { resolve(response.result); } @@ -318,7 +577,10 @@ export class API { }); } - _checkLastError() { + /** + * @param {chrome.runtime.LastError|undefined} _ignore + */ + _checkLastError(_ignore) { // NOP } } diff --git a/ext/js/comm/clipboard-monitor.js b/ext/js/comm/clipboard-monitor.js index c5046046..3b3a56a9 100644 --- a/ext/js/comm/clipboard-monitor.js +++ b/ext/js/comm/clipboard-monitor.js @@ -18,17 +18,32 @@ import {EventDispatcher} from '../core.js'; +/** + * @augments EventDispatcher<import('clipboard-monitor').EventType> + */ export class ClipboardMonitor extends EventDispatcher { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, clipboardReader: import('clipboard-monitor').ClipboardReaderLike}} details + */ constructor({japaneseUtil, clipboardReader}) { super(); + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('clipboard-monitor').ClipboardReaderLike} */ this._clipboardReader = clipboardReader; + /** @type {?import('core').Timeout} */ this._timerId = null; + /** @type {?import('core').TokenObject} */ this._timerToken = null; + /** @type {number} */ this._interval = 250; + /** @type {?string} */ this._previousText = null; } + /** + * @returns {void} + */ start() { this.stop(); @@ -36,6 +51,7 @@ export class ClipboardMonitor extends EventDispatcher { // hasn't been started during the await call. The check below the await call // will exit early if the reference has changed. let canChange = false; + /** @type {?import('core').TokenObject} */ const token = {}; const intervalCallback = async () => { this._timerId = null; @@ -55,7 +71,7 @@ export class ClipboardMonitor extends EventDispatcher { ) { this._previousText = text; if (canChange && this._japaneseUtil.isStringPartiallyJapanese(text)) { - this.trigger('change', {text}); + this.trigger('change', /** @type {import('clipboard-monitor').ChangeEvent} */ ({text})); } } @@ -68,6 +84,9 @@ export class ClipboardMonitor extends EventDispatcher { intervalCallback(); } + /** + * @returns {void} + */ stop() { this._timerToken = null; this._previousText = null; @@ -77,6 +96,9 @@ export class ClipboardMonitor extends EventDispatcher { } } + /** + * @param {?string} text + */ setPreviousText(text) { this._previousText = text; } diff --git a/ext/js/comm/clipboard-reader.js b/ext/js/comm/clipboard-reader.js index 8139cc11..364e31a3 100644 --- a/ext/js/comm/clipboard-reader.js +++ b/ext/js/comm/clipboard-reader.js @@ -24,23 +24,26 @@ import {MediaUtil} from '../media/media-util.js'; export class ClipboardReader { /** * Creates a new instances of a clipboard reader. - * @param {object} details Details about how to set up the instance. - * @param {?Document} details.document The Document object to be used, or null for no support. - * @param {?string} details.pasteTargetSelector The selector for the paste target element. - * @param {?string} details.richContentPasteTargetSelector The selector for the rich content paste target element. + * @param {{document: ?Document, pasteTargetSelector: ?string, richContentPasteTargetSelector: ?string}} details Details about how to set up the instance. */ constructor({document=null, pasteTargetSelector=null, richContentPasteTargetSelector=null}) { + /** @type {?Document} */ this._document = document; + /** @type {?import('environment').Browser} */ this._browser = null; + /** @type {?HTMLTextAreaElement} */ this._pasteTarget = null; + /** @type {?string} */ this._pasteTargetSelector = pasteTargetSelector; + /** @type {?HTMLElement} */ this._richContentPasteTarget = null; + /** @type {?string} */ this._richContentPasteTargetSelector = richContentPasteTargetSelector; } /** * Gets the browser being used. - * @type {?string} + * @type {?import('environment').Browser} */ get browser() { return this._browser; @@ -56,7 +59,7 @@ export class ClipboardReader { /** * Gets the text in the clipboard. * @param {boolean} useRichText Whether or not to use rich text for pasting, when possible. - * @returns {string} A string containing the clipboard text. + * @returns {Promise<string>} A string containing the clipboard text. * @throws {Error} Error if not supported. */ async getText(useRichText) { @@ -90,7 +93,7 @@ export class ClipboardReader { const target = this._getRichContentPasteTarget(); target.focus(); document.execCommand('paste'); - const result = target.textContent; + const result = /** @type {string} */ (target.textContent); this._clearRichContent(target); return result; } else { @@ -106,7 +109,7 @@ export class ClipboardReader { /** * Gets the first image in the clipboard. - * @returns {string} A string containing a data URL of the image file, or null if no image was found. + * @returns {Promise<?string>} A string containing a data URL of the image file, or null if no image was found. * @throws {Error} Error if not supported. */ async getImage() { @@ -155,35 +158,62 @@ export class ClipboardReader { // Private + /** + * @returns {boolean} + */ _isFirefox() { return (this._browser === 'firefox' || this._browser === 'firefox-mobile'); } + /** + * @param {Blob} file + * @returns {Promise<string>} + */ _readFileAsDataURL(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result); + reader.onload = () => resolve(/** @type {string} */ (reader.result)); reader.onerror = () => reject(reader.error); reader.readAsDataURL(file); }); } + /** + * @returns {HTMLTextAreaElement} + */ _getPasteTarget() { - if (this._pasteTarget === null) { this._pasteTarget = this._findPasteTarget(this._pasteTargetSelector); } + if (this._pasteTarget === null) { + this._pasteTarget = /** @type {HTMLTextAreaElement} */ (this._findPasteTarget(this._pasteTargetSelector)); + } return this._pasteTarget; } + /** + * @returns {HTMLElement} + */ _getRichContentPasteTarget() { - if (this._richContentPasteTarget === null) { this._richContentPasteTarget = this._findPasteTarget(this._richContentPasteTargetSelector); } + if (this._richContentPasteTarget === null) { + this._richContentPasteTarget = /** @type {HTMLElement} */ (this._findPasteTarget(this._richContentPasteTargetSelector)); + } return this._richContentPasteTarget; } + /** + * @template {Element} T + * @param {?string} selector + * @returns {T} + * @throws {Error} + */ _findPasteTarget(selector) { - const target = this._document.querySelector(selector); + if (selector === null) { throw new Error('Invalid selector'); } + const target = this._document !== null ? this._document.querySelector(selector) : null; if (target === null) { throw new Error('Clipboard paste target does not exist'); } - return target; + return /** @type {T} */ (target); } + /** + * @param {HTMLElement} element + */ _clearRichContent(element) { for (const image of element.querySelectorAll('img')) { image.removeAttribute('src'); diff --git a/ext/js/comm/cross-frame-api.js b/ext/js/comm/cross-frame-api.js index fe220f21..3ac38cf2 100644 --- a/ext/js/comm/cross-frame-api.js +++ b/ext/js/comm/cross-frame-api.js @@ -16,34 +16,66 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventDispatcher, EventListenerCollection, deserializeError, invokeMessageHandler, log, serializeError} from '../core.js'; +import {EventDispatcher, EventListenerCollection, invokeMessageHandler, log} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {yomitan} from '../yomitan.js'; +/** + * @augments EventDispatcher<import('cross-frame-api').CrossFrameAPIPortEventType> + */ class CrossFrameAPIPort extends EventDispatcher { + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @param {chrome.runtime.Port} port + * @param {import('core').MessageHandlerMap} messageHandlers + */ constructor(otherTabId, otherFrameId, port, messageHandlers) { super(); + /** @type {number} */ this._otherTabId = otherTabId; + /** @type {number} */ this._otherFrameId = otherFrameId; + /** @type {?chrome.runtime.Port} */ this._port = port; + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = messageHandlers; + /** @type {Map<number, import('cross-frame-api').Invocation>} */ this._activeInvocations = new Map(); + /** @type {number} */ this._invocationId = 0; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** @type {number} */ get otherTabId() { return this._otherTabId; } + /** @type {number} */ get otherFrameId() { return this._otherFrameId; } + /** + * @throws {Error} + */ prepare() { + if (this._port === null) { throw new Error('Invalid state'); } this._eventListeners.addListener(this._port.onDisconnect, this._onDisconnect.bind(this)); this._eventListeners.addListener(this._port.onMessage, this._onMessage.bind(this)); } + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {string} action + * @param {TParams} params + * @param {number} ackTimeout + * @param {number} responseTimeout + * @returns {Promise<TReturn>} + */ invoke(action, params, ackTimeout, responseTimeout) { return new Promise((resolve, reject) => { if (this._port === null) { @@ -52,6 +84,7 @@ class CrossFrameAPIPort extends EventDispatcher { } const id = this._invocationId++; + /** @type {import('cross-frame-api').Invocation} */ const invocation = { id, resolve, @@ -73,19 +106,21 @@ class CrossFrameAPIPort extends EventDispatcher { } try { - this._port.postMessage({type: 'invoke', id, data: {action, params}}); + this._port.postMessage(/** @type {import('cross-frame-api').InvokeMessage} */ ({type: 'invoke', id, data: {action, params}})); } catch (e) { this._onError(id, e); } }); } + /** */ disconnect() { this._onDisconnect(); } // Private + /** */ _onDisconnect() { if (this._port === null) { return; } this._eventListeners.removeAllEventListeners(); @@ -96,22 +131,29 @@ class CrossFrameAPIPort extends EventDispatcher { this.trigger('disconnect', this); } - _onMessage({type, id, data}) { + /** + * @param {import('cross-frame-api').Message} details + */ + _onMessage(details) { + const {type, id} = details; switch (type) { case 'invoke': - this._onInvoke(id, data); + this._onInvoke(id, details.data); break; case 'ack': this._onAck(id); break; case 'result': - this._onResult(id, data); + this._onResult(id, details.data); break; } } // Response handlers + /** + * @param {number} id + */ _onAck(id) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { @@ -141,6 +183,10 @@ class CrossFrameAPIPort extends EventDispatcher { } } + /** + * @param {number} id + * @param {import('core').Response<unknown>} data + */ _onResult(id, data) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { @@ -162,17 +208,21 @@ class CrossFrameAPIPort extends EventDispatcher { const error = data.error; if (typeof error !== 'undefined') { - invocation.reject(deserializeError(error)); + invocation.reject(ExtensionError.deserialize(error)); } else { invocation.resolve(data.result); } } + /** + * @param {number} id + * @param {unknown} error + */ _onError(id, error) { const invocation = this._activeInvocations.get(id); if (typeof invocation === 'undefined') { return; } - if (typeof error === 'string') { + if (!(error instanceof Error)) { error = new Error(`${error} (${invocation.action})`); } @@ -186,6 +236,11 @@ class CrossFrameAPIPort extends EventDispatcher { // Invocation + /** + * @param {number} id + * @param {import('cross-frame-api').InvocationData} details + * @returns {boolean} + */ _onInvoke(id, {action, params}) { const messageHandler = this._messageHandlers.get(action); this._sendAck(id); @@ -194,10 +249,17 @@ class CrossFrameAPIPort extends EventDispatcher { return false; } + /** + * @param {import('core').Response<unknown>} data + * @returns {void} + */ const callback = (data) => this._sendResult(id, data); return invokeMessageHandler(messageHandler, params, callback); } + /** + * @param {import('cross-frame-api').Message} data + */ _sendResponse(data) { if (this._port === null) { return; } try { @@ -207,45 +269,90 @@ class CrossFrameAPIPort extends EventDispatcher { } } + /** + * @param {number} id + */ _sendAck(id) { this._sendResponse({type: 'ack', id}); } + /** + * @param {number} id + * @param {import('core').Response<unknown>} data + */ _sendResult(id, data) { this._sendResponse({type: 'result', id, data}); } + /** + * @param {number} id + * @param {Error} error + */ _sendError(id, error) { - this._sendResponse({type: 'result', id, data: {error: serializeError(error)}}); + this._sendResponse({type: 'result', id, data: {error: ExtensionError.serialize(error)}}); } } export class CrossFrameAPI { constructor() { + /** @type {number} */ this._ackTimeout = 3000; // 3 seconds + /** @type {number} */ this._responseTimeout = 10000; // 10 seconds + /** @type {Map<number, Map<number, CrossFrameAPIPort>>} */ this._commPorts = new Map(); + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = new Map(); + /** @type {(port: CrossFrameAPIPort) => void} */ this._onDisconnectBind = this._onDisconnect.bind(this); + /** @type {?number} */ this._tabId = null; + /** @type {?number} */ this._frameId = null; } + /** */ async prepare() { chrome.runtime.onConnect.addListener(this._onConnect.bind(this)); - ({tabId: this._tabId, frameId: this._frameId} = await yomitan.api.frameInformationGet()); + ({tabId: this._tabId = null, frameId: this._frameId = null} = await yomitan.api.frameInformationGet()); } - invoke(targetFrameId, action, params={}) { + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {number} targetFrameId + * @param {string} action + * @param {TParams} params + * @returns {Promise<TReturn>} + */ + invoke(targetFrameId, action, params) { return this.invokeTab(null, targetFrameId, action, params); } - async invokeTab(targetTabId, targetFrameId, action, params={}) { - if (typeof targetTabId !== 'number') { targetTabId = this._tabId; } + /** + * @template [TParams=import('core').SerializableObject] + * @template [TReturn=unknown] + * @param {?number} targetTabId + * @param {number} targetFrameId + * @param {string} action + * @param {TParams} params + * @returns {Promise<TReturn>} + */ + async invokeTab(targetTabId, targetFrameId, action, params) { + if (typeof targetTabId !== 'number') { + targetTabId = this._tabId; + if (typeof targetTabId !== 'number') { + throw new Error('Unknown target tab id for invocation'); + } + } const commPort = await this._getOrCreateCommPort(targetTabId, targetFrameId); return await commPort.invoke(action, params, this._ackTimeout, this._responseTimeout); } + /** + * @param {import('core').MessageHandlerArray} messageHandlers + * @throws {Error} + */ registerHandlers(messageHandlers) { for (const [key, value] of messageHandlers) { if (this._messageHandlers.has(key)) { @@ -255,12 +362,19 @@ export class CrossFrameAPI { } } + /** + * @param {string} key + * @returns {boolean} + */ unregisterHandler(key) { return this._messageHandlers.delete(key); } // Private + /** + * @param {chrome.runtime.Port} port + */ _onConnect(port) { try { let details; @@ -280,6 +394,9 @@ export class CrossFrameAPI { } } + /** + * @param {CrossFrameAPIPort} commPort + */ _onDisconnect(commPort) { commPort.off('disconnect', this._onDisconnectBind); const {otherTabId, otherFrameId} = commPort; @@ -292,7 +409,12 @@ export class CrossFrameAPI { } } - _getOrCreateCommPort(otherTabId, otherFrameId) { + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @returns {Promise<CrossFrameAPIPort>} + */ + async _getOrCreateCommPort(otherTabId, otherFrameId) { const tabPorts = this._commPorts.get(otherTabId); if (typeof tabPorts !== 'undefined') { const commPort = tabPorts.get(otherFrameId); @@ -300,9 +422,13 @@ export class CrossFrameAPI { return commPort; } } - return this._createCommPort(otherTabId, otherFrameId); + return await this._createCommPort(otherTabId, otherFrameId); } - + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @returns {Promise<CrossFrameAPIPort>} + */ async _createCommPort(otherTabId, otherFrameId) { await yomitan.api.openCrossFramePort(otherTabId, otherFrameId); @@ -313,8 +439,15 @@ export class CrossFrameAPI { return commPort; } } + throw new Error('Comm port didn\'t open'); } + /** + * @param {number} otherTabId + * @param {number} otherFrameId + * @param {chrome.runtime.Port} port + * @returns {CrossFrameAPIPort} + */ _setupCommPort(otherTabId, otherFrameId, port) { const commPort = new CrossFrameAPIPort(otherTabId, otherFrameId, port, this._messageHandlers); let tabPorts = this._commPorts.get(otherTabId); diff --git a/ext/js/comm/frame-ancestry-handler.js b/ext/js/comm/frame-ancestry-handler.js index eeefac3f..e4d08f28 100644 --- a/ext/js/comm/frame-ancestry-handler.js +++ b/ext/js/comm/frame-ancestry-handler.js @@ -31,11 +31,17 @@ export class FrameAncestryHandler { * @param {number} frameId The frame ID of the current frame the instance is instantiated in. */ constructor(frameId) { + /** @type {number} */ this._frameId = frameId; + /** @type {boolean} */ this._isPrepared = false; + /** @type {string} */ this._requestMessageId = 'FrameAncestryHandler.requestFrameInfo'; + /** @type {string} */ this._responseMessageIdBase = `${this._requestMessageId}.response.`; + /** @type {?Promise<number[]>} */ this._getFrameAncestryInfoPromise = null; + /** @type {Map<number, {window: Window, frameElement: ?(undefined|Element)}>} */ this._childFrameMap = new Map(); } @@ -68,7 +74,7 @@ export class FrameAncestryHandler { * Gets the frame ancestry information for the current frame. If the frame is the * root frame, an empty array is returned. Otherwise, an array of frame IDs is returned, * starting from the nearest ancestor. - * @returns {number[]} An array of frame IDs corresponding to the ancestors of the current frame. + * @returns {Promise<number[]>} An array of frame IDs corresponding to the ancestors of the current frame. */ async getFrameAncestryInfo() { if (this._getFrameAncestryInfoPromise === null) { @@ -82,7 +88,7 @@ export class FrameAncestryHandler { * For this function to work, the `getFrameAncestryInfo` function needs to have * been invoked previously. * @param {number} frameId The frame ID of the child frame to get. - * @returns {HTMLElement} The element corresponding to the frame with ID `frameId`, otherwise `null`. + * @returns {?Element} The element corresponding to the frame with ID `frameId`, otherwise `null`. */ getChildFrameElement(frameId) { const frameInfo = this._childFrameMap.get(frameId); @@ -99,6 +105,10 @@ export class FrameAncestryHandler { // Private + /** + * @param {number} [timeout] + * @returns {Promise<number[]>} + */ _getFrameAncestryInfo(timeout=5000) { return new Promise((resolve, reject) => { const targetWindow = window.parent; @@ -110,7 +120,9 @@ export class FrameAncestryHandler { const uniqueId = generateId(16); let nonce = generateId(16); const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; + /** @type {number[]} */ const results = []; + /** @type {?import('core').Timeout} */ let timer = null; const cleanup = () => { @@ -120,6 +132,10 @@ export class FrameAncestryHandler { } yomitan.crossFrame.unregisterHandler(responseMessageId); }; + /** + * @param {import('frame-ancestry-handler').RequestFrameInfoResponseParams} params + * @returns {?import('frame-ancestry-handler').RequestFrameInfoResponseReturn} + */ const onMessage = (params) => { if (params.nonce !== nonce) { return null; } @@ -155,24 +171,35 @@ export class FrameAncestryHandler { }); } + /** + * @param {MessageEvent<unknown>} event + */ _onWindowMessage(event) { - const {source} = event; - if (source === window || source.parent !== window) { return; } + const source = /** @type {?Window} */ (event.source); + if (source === null || source === window || source.parent !== window) { return; } const {data} = event; - if ( - typeof data === 'object' && - data !== null && - data.action === this._requestMessageId - ) { - this._onRequestFrameInfo(data.params, source); - } + if (typeof data !== 'object' || data === null) { return; } + + const {action} = /** @type {import('core').SerializableObject} */ (data); + if (action !== this._requestMessageId) { return; } + + const {params} = /** @type {import('core').SerializableObject} */ (data); + if (typeof params !== 'object' || params === null) { return; } + + this._onRequestFrameInfo(/** @type {import('core').SerializableObject} */ (params), source); } + /** + * @param {import('core').SerializableObject} params + * @param {Window} source + */ async _onRequestFrameInfo(params, source) { try { let {originFrameId, childFrameId, uniqueId, nonce} = params; if ( + typeof originFrameId !== 'number' || + typeof childFrameId !== 'number' || !this._isNonNegativeInteger(originFrameId) || typeof uniqueId !== 'string' || typeof nonce !== 'string' @@ -183,13 +210,17 @@ export class FrameAncestryHandler { const frameId = this._frameId; const {parent} = window; const more = (window !== parent); + /** @type {import('frame-ancestry-handler').RequestFrameInfoResponseParams} */ const responseParams = {frameId, nonce, more}; const responseMessageId = `${this._responseMessageIdBase}${uniqueId}`; try { + /** @type {?import('frame-ancestry-handler').RequestFrameInfoResponseReturn} */ const response = await yomitan.crossFrame.invoke(originFrameId, responseMessageId, responseParams); if (response === null) { return; } - nonce = response.nonce; + const nonce2 = response.nonce; + if (typeof nonce2 !== 'string') { return; } + nonce = nonce2; } catch (e) { return; } @@ -199,13 +230,20 @@ export class FrameAncestryHandler { } if (more) { - this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, nonce); + this._requestFrameInfo(parent, originFrameId, frameId, uniqueId, /** @type {string} */ (nonce)); } } catch (e) { // NOP } } + /** + * @param {Window} targetWindow + * @param {number} originFrameId + * @param {number} childFrameId + * @param {string} uniqueId + * @param {string} nonce + */ _requestFrameInfo(targetWindow, originFrameId, childFrameId, uniqueId, nonce) { targetWindow.postMessage({ action: this._requestMessageId, @@ -213,15 +251,22 @@ export class FrameAncestryHandler { }, '*'); } + /** + * @param {number} value + * @returns {boolean} + */ _isNonNegativeInteger(value) { return ( - typeof value === 'number' && Number.isFinite(value) && value >= 0 && Math.floor(value) === value ); } + /** + * @param {Window} contentWindow + * @returns {?Element} + */ _findFrameElementWithContentWindow(contentWindow) { // Check frameElement, for non-null same-origin frames try { @@ -232,9 +277,9 @@ export class FrameAncestryHandler { } // Check frames - const frameTypes = ['iframe', 'frame', 'embed']; + const frameTypes = ['iframe', 'frame', 'object']; for (const frameType of frameTypes) { - for (const frame of document.getElementsByTagName(frameType)) { + for (const frame of /** @type {HTMLCollectionOf<import('extension').HtmlElementWithContentWindow>} */ (document.getElementsByTagName(frameType))) { if (frame.contentWindow === contentWindow) { return frame; } @@ -242,20 +287,24 @@ export class FrameAncestryHandler { } // Check for shadow roots + /** @type {Node[]} */ const rootElements = [document.documentElement]; while (rootElements.length > 0) { - const rootElement = rootElements.shift(); + const rootElement = /** @type {Node} */ (rootElements.shift()); const walker = document.createTreeWalker(rootElement, NodeFilter.SHOW_ELEMENT); while (walker.nextNode()) { - const element = walker.currentNode; + const element = /** @type {Element} */ (walker.currentNode); + // @ts-expect-error - this is more simple to elide any type checks or casting if (element.contentWindow === contentWindow) { return element; } + /** @type {?ShadowRoot|undefined} */ const shadowRoot = ( element.shadowRoot || - element.openOrClosedShadowRoot // Available to Firefox 63+ for WebExtensions + // @ts-expect-error - openOrClosedShadowRoot is available to Firefox 63+ for WebExtensions + element.openOrClosedShadowRoot ); if (shadowRoot) { rootElements.push(shadowRoot); diff --git a/ext/js/comm/frame-client.js b/ext/js/comm/frame-client.js index 0ca37feb..8aa8c6d6 100644 --- a/ext/js/comm/frame-client.js +++ b/ext/js/comm/frame-client.js @@ -20,47 +20,81 @@ import {deferPromise, generateId, isObject} from '../core.js'; export class FrameClient { constructor() { + /** @type {?string} */ this._secret = null; + /** @type {?string} */ this._token = null; + /** @type {?number} */ this._frameId = null; } + /** @type {number} */ get frameId() { + if (this._frameId === null) { throw new Error('Not connected'); } return this._frameId; } + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @param {string} targetOrigin + * @param {number} hostFrameId + * @param {import('frame-client').SetupFrameFunction} setupFrame + * @param {number} [timeout] + */ async connect(frame, targetOrigin, hostFrameId, setupFrame, timeout=10000) { - const {secret, token, frameId} = await this._connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout); + const {secret, token, frameId} = await this._connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout); this._secret = secret; this._token = token; this._frameId = frameId; } + /** + * @returns {boolean} + */ isConnected() { return (this._secret !== null); } + /** + * @template T + * @param {T} data + * @returns {import('frame-client').Message<T>} + * @throws {Error} + */ createMessage(data) { if (!this.isConnected()) { throw new Error('Not connected'); } return { - token: this._token, - secret: this._secret, + token: /** @type {string} */ (this._token), + secret: /** @type {string} */ (this._secret), data }; } - _connectIternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) { + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @param {string} targetOrigin + * @param {number} hostFrameId + * @param {(frame: import('extension').HtmlElementWithContentWindow) => void} setupFrame + * @param {number} timeout + * @returns {Promise<{secret: string, token: string, frameId: number}>} + */ + _connectInternal(frame, targetOrigin, hostFrameId, setupFrame, timeout) { return new Promise((resolve, reject) => { const tokenMap = new Map(); + /** @type {?import('core').Timeout} */ let timer = null; - let { - promise: frameLoadedPromise, - resolve: frameLoadedResolve, - reject: frameLoadedReject - } = deferPromise(); - + const deferPromiseDetails = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + const frameLoadedPromise = deferPromiseDetails.promise; + let frameLoadedResolve = /** @type {?() => void} */ (deferPromiseDetails.resolve); + let frameLoadedReject = /** @type {?(reason?: import('core').RejectionReason) => void} */ (deferPromiseDetails.reject); + + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @throws {Error} + */ const postMessage = (action, params) => { const contentWindow = frame.contentWindow; if (contentWindow === null) { throw new Error('Frame missing content window'); } @@ -76,11 +110,15 @@ export class FrameClient { contentWindow.postMessage({action, params}, targetOrigin); }; + /** @type {import('extension').ChromeRuntimeOnMessageCallback<import('extension').ChromeRuntimeMessageWithFrameId>} */ const onMessage = (message) => { onMessageInner(message); return false; }; + /** + * @param {import('extension').ChromeRuntimeMessageWithFrameId} message + */ const onMessageInner = async (message) => { try { if (!isObject(message)) { return; } @@ -92,7 +130,7 @@ export class FrameClient { switch (action) { case 'frameEndpointReady': { - const {secret} = params; + const {secret} = /** @type {import('frame-client').FrameEndpointReadyDetails} */ (params); const token = generateId(16); tokenMap.set(secret, token); postMessage('frameEndpointConnect', {secret, token, hostFrameId}); @@ -100,10 +138,10 @@ export class FrameClient { break; case 'frameEndpointConnected': { - const {secret, token} = params; + const {secret, token} = /** @type {import('frame-client').FrameEndpointConnectedDetails} */ (params); const frameId = message.frameId; const token2 = tokenMap.get(secret); - if (typeof token2 !== 'undefined' && token === token2) { + if (typeof token2 !== 'undefined' && token === token2 && typeof frameId === 'number') { cleanup(); resolve({secret, token, frameId}); } @@ -168,6 +206,10 @@ export class FrameClient { }); } + /** + * @param {import('extension').HtmlElementWithContentWindow} frame + * @returns {boolean} + */ static isFrameAboutBlank(frame) { try { const contentDocument = frame.contentDocument; diff --git a/ext/js/comm/frame-endpoint.js b/ext/js/comm/frame-endpoint.js index 5555e60f..c338e143 100644 --- a/ext/js/comm/frame-endpoint.js +++ b/ext/js/comm/frame-endpoint.js @@ -16,50 +16,73 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventListenerCollection, generateId, isObject} from '../core.js'; +import {EventListenerCollection, generateId} from '../core.js'; import {yomitan} from '../yomitan.js'; export class FrameEndpoint { constructor() { + /** @type {string} */ this._secret = generateId(16); + /** @type {?string} */ this._token = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {boolean} */ this._eventListenersSetup = false; } + /** + * @returns {void} + */ signal() { if (!this._eventListenersSetup) { this._eventListeners.addEventListener(window, 'message', this._onMessage.bind(this), false); this._eventListenersSetup = true; } - yomitan.api.broadcastTab('frameEndpointReady', {secret: this._secret}); + /** @type {import('frame-client').FrameEndpointReadyDetails} */ + const details = {secret: this._secret}; + yomitan.api.broadcastTab('frameEndpointReady', details); } + /** + * @param {unknown} message + * @returns {boolean} + */ authenticate(message) { return ( this._token !== null && - isObject(message) && - this._token === message.token && - this._secret === message.secret + typeof message === 'object' && message !== null && + this._token === /** @type {import('core').SerializableObject} */ (message).token && + this._secret === /** @type {import('core').SerializableObject} */ (message).secret ); } - _onMessage(e) { + /** + * @param {MessageEvent<unknown>} event + */ + _onMessage(event) { if (this._token !== null) { return; } // Already initialized - const data = e.data; - if (!isObject(data) || data.action !== 'frameEndpointConnect') { return; } // Invalid message + const {data} = event; + if (typeof data !== 'object' || data === null) { return; } // Invalid message - const params = data.params; - if (!isObject(params)) { return; } // Invalid data + const {action} = /** @type {import('core').SerializableObject} */ (data); + if (action !== 'frameEndpointConnect') { return; } // Invalid message - const secret = params.secret; + const {params} = /** @type {import('core').SerializableObject} */ (data); + if (typeof params !== 'object' || params === null) { return; } // Invalid data + + const {secret} = /** @type {import('core').SerializableObject} */ (params); if (secret !== this._secret) { return; } // Invalid authentication - const {token, hostFrameId} = params; + const {token, hostFrameId} = /** @type {import('core').SerializableObject} */ (params); + if (typeof token !== 'string' || typeof hostFrameId !== 'number') { return; } // Invalid target + this._token = token; this._eventListeners.removeAllEventListeners(); - yomitan.api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', {secret, token}); + /** @type {import('frame-client').FrameEndpointConnectedDetails} */ + const details = {secret, token}; + yomitan.api.sendMessageToFrame(hostFrameId, 'frameEndpointConnected', details); } } diff --git a/ext/js/comm/frame-offset-forwarder.js b/ext/js/comm/frame-offset-forwarder.js index ef75f1d0..af9bd268 100644 --- a/ext/js/comm/frame-offset-forwarder.js +++ b/ext/js/comm/frame-offset-forwarder.js @@ -20,11 +20,19 @@ import {yomitan} from '../yomitan.js'; import {FrameAncestryHandler} from './frame-ancestry-handler.js'; export class FrameOffsetForwarder { + /** + * @param {number} frameId + */ constructor(frameId) { + /** @type {number} */ this._frameId = frameId; + /** @type {FrameAncestryHandler} */ this._frameAncestryHandler = new FrameAncestryHandler(frameId); } + /** + * @returns {void} + */ prepare() { this._frameAncestryHandler.prepare(); yomitan.crossFrame.registerHandlers([ @@ -32,6 +40,9 @@ export class FrameOffsetForwarder { ]); } + /** + * @returns {Promise<?[x: number, y: number]>} + */ async getOffset() { if (this._frameAncestryHandler.isRootFrame()) { return [0, 0]; @@ -41,6 +52,7 @@ export class FrameOffsetForwarder { const ancestorFrameIds = await this._frameAncestryHandler.getFrameAncestryInfo(); let childFrameId = this._frameId; + /** @type {Promise<?import('frame-offset-forwarder').ChildFrameRect>[]} */ const promises = []; for (const frameId of ancestorFrameIds) { promises.push(yomitan.crossFrame.invoke(frameId, 'FrameOffsetForwarder.getChildFrameRect', {frameId: childFrameId})); @@ -64,6 +76,10 @@ export class FrameOffsetForwarder { // Private + /** + * @param {{frameId: number}} event + * @returns {?import('frame-offset-forwarder').ChildFrameRect} + */ _onMessageGetChildFrameRect({frameId}) { const frameElement = this._frameAncestryHandler.getChildFrameElement(frameId); if (frameElement === null) { return null; } diff --git a/ext/js/comm/mecab.js b/ext/js/comm/mecab.js index c7314605..0a87463b 100644 --- a/ext/js/comm/mecab.js +++ b/ext/js/comm/mecab.js @@ -24,32 +24,26 @@ import {EventListenerCollection} from '../core.js'; */ export class Mecab { /** - * The resulting data from an invocation of `parseText`. - * @typedef {object} ParseResult - * @property {string} name The dictionary name for the parsed result. - * @property {ParseTerm[]} lines The resulting parsed terms. - */ - - /** - * A fragment of the parsed text. - * @typedef {object} ParseFragment - * @property {string} term The term. - * @property {string} reading The reading of the term. - * @property {string} source The source text. - */ - - /** * Creates a new instance of the class. */ constructor() { + /** @type {?chrome.runtime.Port} */ this._port = null; + /** @type {number} */ this._sequence = 0; + /** @type {Map<number, {resolve: (value: unknown) => void, reject: (reason?: unknown) => void, timer: import('core').Timeout}>} */ this._invocations = new Map(); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {number} */ this._timeout = 5000; + /** @type {number} */ this._version = 1; + /** @type {?number} */ this._remoteVersion = null; + /** @type {boolean} */ this._enabled = false; + /** @type {?Promise<void>} */ this._setupPortPromise = null; } @@ -107,7 +101,7 @@ export class Mecab { /** * Gets the version of the MeCab component. - * @returns {?number} The version of the MeCab component, or `null` if the component was not found. + * @returns {Promise<?number>} The version of the MeCab component, or `null` if the component was not found. */ async getVersion() { try { @@ -135,17 +129,26 @@ export class Mecab { * ] * ``` * @param {string} text The string to parse. - * @returns {ParseResult[]} A collection of parsing results of the text. + * @returns {Promise<import('mecab').ParseResult[]>} A collection of parsing results of the text. */ async parseText(text) { await this._setupPort(); const rawResults = await this._invoke('parse_text', {text}); - return this._convertParseTextResults(rawResults); + // Note: The format of rawResults is not validated + return this._convertParseTextResults(/** @type {import('mecab').ParseResultRaw} */ (rawResults)); } // Private - _onMessage({sequence, data}) { + /** + * @param {unknown} message + */ + _onMessage(message) { + if (typeof message !== 'object' || message === null) { return; } + + const {sequence, data} = /** @type {import('core').SerializableObject} */ (message); + if (typeof sequence !== 'number') { return; } + const invocation = this._invocations.get(sequence); if (typeof invocation === 'undefined') { return; } @@ -155,6 +158,9 @@ export class Mecab { this._invocations.delete(sequence); } + /** + * @returns {void} + */ _onDisconnect() { if (this._port === null) { return; } const e = chrome.runtime.lastError; @@ -166,10 +172,16 @@ export class Mecab { this._clearPort(); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @returns {Promise<unknown>} + */ _invoke(action, params) { return new Promise((resolve, reject) => { if (this._port === null) { reject(new Error('Port disconnected')); + return; } const sequence = this._sequence++; @@ -179,15 +191,21 @@ export class Mecab { reject(new Error(`MeCab invoke timed out after ${this._timeout}ms`)); }, this._timeout); - this._invocations.set(sequence, {resolve, reject, timer}, this._timeout); + this._invocations.set(sequence, {resolve, reject, timer}); this._port.postMessage({action, params, sequence}); }); } + /** + * @param {import('mecab').ParseResultRaw} rawResults + * @returns {import('mecab').ParseResult[]} + */ _convertParseTextResults(rawResults) { + /** @type {import('mecab').ParseResult[]} */ const results = []; for (const [name, rawLines] of Object.entries(rawResults)) { + /** @type {import('mecab').ParseFragment[][]} */ const lines = []; for (const rawLine of rawLines) { const line = []; @@ -204,6 +222,9 @@ export class Mecab { return results; } + /** + * @returns {Promise<void>} + */ async _setupPort() { if (!this._enabled) { throw new Error('MeCab not enabled'); @@ -214,10 +235,13 @@ export class Mecab { try { await this._setupPortPromise; } catch (e) { - throw new Error(e.message); + throw new Error(e instanceof Error ? e.message : `${e}`); } } + /** + * @returns {Promise<void>} + */ async _setupPort2() { const port = chrome.runtime.connectNative('yomitan_mecab'); this._eventListeners.addListener(port.onMessage, this._onMessage.bind(this)); @@ -225,7 +249,14 @@ export class Mecab { this._port = port; try { - const {version} = await this._invoke('get_version', {}); + const data = await this._invoke('get_version', {}); + if (typeof data !== 'object' || data === null) { + throw new Error('Invalid version'); + } + const {version} = /** @type {import('core').SerializableObject} */ (data); + if (typeof version !== 'number') { + throw new Error('Invalid version'); + } this._remoteVersion = version; if (version !== this._version) { throw new Error(`Unsupported MeCab native messenger version ${version}. Yomitan supports version ${this._version}.`); @@ -238,9 +269,14 @@ export class Mecab { } } + /** + * @returns {void} + */ _clearPort() { - this._port.disconnect(); - this._port = null; + if (this._port !== null) { + this._port.disconnect(); + this._port = null; + } this._invocations.clear(); this._eventListeners.removeAllEventListeners(); this._sequence = 0; diff --git a/ext/js/core.js b/ext/js/core.js index fb164795..5c03b44b 100644 --- a/ext/js/core.js +++ b/ext/js/core.js @@ -16,54 +16,11 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -/** - * Converts an `Error` object to a serializable JSON object. - * @param {*} error An error object to convert. - * @returns {{name: string, message: string, stack: string, data?: *}|{value: *, hasValue: boolean}} A simple object which can be serialized by `JSON.stringify()`. - */ -export function serializeError(error) { - try { - if (typeof error === 'object' && error !== null) { - const result = { - name: error.name, - message: error.message, - stack: error.stack - }; - if (Object.prototype.hasOwnProperty.call(error, 'data')) { - result.data = error.data; - } - return result; - } - } catch (e) { - // NOP - } - return { - value: error, - hasValue: true - }; -} - -/** - * Converts a serialized erorr into a standard `Error` object. - * @param {{name: string, message: string, stack: string, data?: *}|{value: *, hasValue: boolean}} serializedError A simple object which was initially generated by serializeError. - * @returns {Error|*} A new `Error` instance. - */ -export function deserializeError(serializedError) { - if (serializedError.hasValue) { - return serializedError.value; - } - const error = new Error(serializedError.message); - error.name = serializedError.name; - error.stack = serializedError.stack; - if (Object.prototype.hasOwnProperty.call(serializedError, 'data')) { - error.data = serializedError.data; - } - return error; -} +import {ExtensionError} from './core/extension-error.js'; /** * Checks whether a given value is a non-array object. - * @param {*} value The value to check. + * @param {unknown} value The value to check. * @returns {boolean} `true` if the value is an object and not an array, `false` otherwise. */ export function isObject(value) { @@ -91,14 +48,20 @@ export function stringReverse(string) { /** * Creates a deep clone of an object or value. This is similar to `JSON.parse(JSON.stringify(value))`. - * @param {*} value The value to clone. - * @returns {*} A new clone of the value. + * @template T + * @param {T} value The value to clone. + * @returns {T} A new clone of the value. * @throws An error if the value is circular and cannot be cloned. */ export const clone = (() => { - // eslint-disable-next-line no-shadow + /** + * @template T + * @param {T} value + * @returns {T} + */ + // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow function clone(value) { - if (value === null) { return null; } + if (value === null) { return /** @type {T} */ (null); } switch (typeof value) { case 'boolean': case 'number': @@ -112,8 +75,15 @@ export const clone = (() => { } } + /** + * @template [T=unknown] + * @param {T} value + * @param {Set<unknown>} visited + * @returns {T} + * @throws {Error} + */ function cloneInternal(value, visited) { - if (value === null) { return null; } + if (value === null) { return /** @type {T} */ (null); } switch (typeof value) { case 'boolean': case 'number': @@ -122,13 +92,23 @@ export const clone = (() => { case 'symbol': case 'undefined': return value; - case 'function': - return cloneObject(value, visited); case 'object': - return Array.isArray(value) ? cloneArray(value, visited) : cloneObject(value, visited); + return /** @type {T} */ ( + Array.isArray(value) ? + cloneArray(value, visited) : + cloneObject(/** @type {import('core').SerializableObject} */ (value), visited) + ); + default: + throw new Error(`Cannot clone object of type ${typeof value}`); } } + /** + * @param {unknown[]} value + * @param {Set<unknown>} visited + * @returns {unknown[]} + * @throws {Error} + */ function cloneArray(value, visited) { if (visited.has(value)) { throw new Error('Circular'); } try { @@ -143,10 +123,17 @@ export const clone = (() => { } } + /** + * @param {import('core').SerializableObject} value + * @param {Set<unknown>} visited + * @returns {import('core').SerializableObject} + * @throws {Error} + */ function cloneObject(value, visited) { if (visited.has(value)) { throw new Error('Circular'); } try { visited.add(value); + /** @type {import('core').SerializableObject} */ const result = {}; for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { @@ -164,12 +151,17 @@ export const clone = (() => { /** * Checks if an object or value is deeply equal to another object or value. - * @param {*} value1 The first value to check. - * @param {*} value2 The second value to check. + * @param {unknown} value1 The first value to check. + * @param {unknown} value2 The second value to check. * @returns {boolean} `true` if the values are the same object, or deeply equal without cycles. `false` otherwise. */ export const deepEqual = (() => { - // eslint-disable-next-line no-shadow + /** + * @param {unknown} value1 + * @param {unknown} value2 + * @returns {boolean} + */ + // eslint-disable-next-line no-shadow, @typescript-eslint/no-shadow function deepEqual(value1, value2) { if (value1 === value2) { return true; } @@ -185,6 +177,12 @@ export const deepEqual = (() => { } } + /** + * @param {unknown} value1 + * @param {unknown} value2 + * @param {Set<unknown>} visited1 + * @returns {boolean} + */ function deepEqualInternal(value1, value2, visited1) { if (value1 === value2) { return true; } @@ -200,13 +198,23 @@ export const deepEqual = (() => { if (array !== Array.isArray(value2)) { return false; } if (visited1.has(value1)) { return false; } visited1.add(value1); - return array ? areArraysEqual(value1, value2, visited1) : areObjectsEqual(value1, value2, visited1); + return ( + array ? + areArraysEqual(/** @type {unknown[]} */ (value1), /** @type {unknown[]} */ (value2), visited1) : + areObjectsEqual(/** @type {import('core').UnknownObject} */ (value1), /** @type {import('core').UnknownObject} */ (value2), visited1) + ); } default: return false; } } + /** + * @param {import('core').UnknownObject} value1 + * @param {import('core').UnknownObject} value2 + * @param {Set<unknown>} visited1 + * @returns {boolean} + */ function areObjectsEqual(value1, value2, visited1) { const keys1 = Object.keys(value1); const keys2 = Object.keys(value2); @@ -220,6 +228,12 @@ export const deepEqual = (() => { return true; } + /** + * @param {unknown[]} value1 + * @param {unknown[]} value2 + * @param {Set<unknown>} visited1 + * @returns {boolean} + */ function areArraysEqual(value1, value2, visited1) { const length = value1.length; if (length !== value2.length) { return false; } @@ -251,76 +265,55 @@ export function generateId(length) { /** * Creates an unresolved promise that can be resolved later, outside the promise's executor function. - * @returns {{promise: Promise, resolve: Function, reject: Function}} An object `{promise, resolve, reject}`, containing the promise and the resolve/reject functions. + * @template T + * @returns {import('core').DeferredPromiseDetails<T>} An object `{promise, resolve, reject}`, containing the promise and the resolve/reject functions. */ export function deferPromise() { + /** @type {((value: T) => void)|undefined} */ let resolve; + /** @type {((reason?: import('core').RejectionReason) => void)|undefined} */ let reject; const promise = new Promise((resolve2, reject2) => { resolve = resolve2; reject = reject2; }); - return {promise, resolve, reject}; + return { + promise, + resolve: /** @type {(value: T) => void} */ (resolve), + reject: /** @type {(reason?: import('core').RejectionReason) => void} */ (reject) + }; } /** * Creates a promise that is resolved after a set delay. * @param {number} delay How many milliseconds until the promise should be resolved. If 0, the promise is immediately resolved. - * @param {*} [resolveValue] The value returned when the promise is resolved. - * @returns {Promise} A promise with two additional properties: `resolve` and `reject`, which can be used to complete the promise early. + * @returns {Promise<void>} A promise with two additional properties: `resolve` and `reject`, which can be used to complete the promise early. */ -export function promiseTimeout(delay, resolveValue) { - if (delay <= 0) { - const promise = Promise.resolve(resolveValue); - promise.resolve = () => {}; // NOP - promise.reject = () => {}; // NOP - return promise; - } - - let timer = null; - let {promise, resolve, reject} = deferPromise(); - - const complete = (callback, value) => { - if (callback === null) { return; } - if (timer !== null) { - clearTimeout(timer); - timer = null; - } - resolve = null; - reject = null; - callback(value); - }; - - const resolveWrapper = (value) => complete(resolve, value); - const rejectWrapper = (value) => complete(reject, value); - - timer = setTimeout(() => { - timer = null; - resolveWrapper(resolveValue); - }, delay); - - promise.resolve = resolveWrapper; - promise.reject = rejectWrapper; - - return promise; +export function promiseTimeout(delay) { + return delay <= 0 ? Promise.resolve() : new Promise((resolve) => { setTimeout(resolve, delay); }); } /** * Creates a promise that will resolve after the next animation frame, using `requestAnimationFrame`. * @param {number} [timeout] A maximum duration (in milliseconds) to wait until the promise resolves. If null or omitted, no timeout is used. - * @returns {Promise<{time: number, timeout: number}>} A promise that is resolved with `{time, timeout}`, where `time` is the timestamp from `requestAnimationFrame`, + * @returns {Promise<{time: number, timeout: boolean}>} A promise that is resolved with `{time, timeout}`, where `time` is the timestamp from `requestAnimationFrame`, * and `timeout` is a boolean indicating whether the cause was a timeout or not. * @throws The promise throws an error if animation is not supported in this context, such as in a service worker. */ -export function promiseAnimationFrame(timeout=null) { +export function promiseAnimationFrame(timeout) { return new Promise((resolve, reject) => { if (typeof cancelAnimationFrame !== 'function' || typeof requestAnimationFrame !== 'function') { reject(new Error('Animation not supported in this context')); return; } + /** @type {?import('core').Timeout} */ let timer = null; + /** @type {?number} */ let frameRequest = null; + /** + * @param {number} time + */ const onFrame = (time) => { frameRequest = null; if (timer !== null) { @@ -350,14 +343,12 @@ export function promiseAnimationFrame(timeout=null) { /** * Invokes a standard message handler. This function is used to react and respond * to communication messages within the extension. - * @param {object} details Details about how to handle messages. - * @param {Function} details.handler A handler function which is passed `params` and `...extraArgs` as arguments. - * @param {boolean|string} details.async Whether or not the handler is async or not. Values include `false`, `true`, or `'dynamic'`. - * When the value is `'dynamic'`, the handler should return an object of the format `{async: boolean, result: any}`. - * @param {object} params Information which was passed with the original message. - * @param {Function} callback A callback function which is invoked after the handler has completed. The value passed + * @template {import('core').SafeAny} TParams + * @param {import('core').MessageHandlerDetails} details Details about how to handle messages. + * @param {TParams} params Information which was passed with the original message. + * @param {(response: import('core').Response) => void} callback A callback function which is invoked after the handler has completed. The value passed * to the function is in the format: - * - `{result: any}` if the handler invoked successfully. + * - `{result: unknown}` if the handler invoked successfully. * - `{error: object}` if the handler thew an error. The error is serialized. * @param {...*} extraArgs Additional arguments which are passed to the `handler` function. * @returns {boolean} `true` if the function is invoked asynchronously, `false` otherwise. @@ -369,9 +360,9 @@ export function invokeMessageHandler({handler, async}, params, callback, ...extr ({async, result: promiseOrResult} = promiseOrResult); } if (async) { - promiseOrResult.then( + /** @type {Promise<any>} */ (promiseOrResult).then( (result) => { callback({result}); }, - (error) => { callback({error: serializeError(error)}); } + (error) => { callback({error: ExtensionError.serialize(error)}); } ); return true; } else { @@ -379,12 +370,13 @@ export function invokeMessageHandler({handler, async}, params, callback, ...extr return false; } } catch (error) { - callback({error: serializeError(error)}); + callback({error: ExtensionError.serialize(error)}); return false; } } /** + * @template {string} TEventName * Base class controls basic event dispatching. */ export class EventDispatcher { @@ -392,13 +384,14 @@ export class EventDispatcher { * Creates a new instance. */ constructor() { + /** @type {Map<string, ((details: import('core').SafeAny) => void)[]>} */ this._eventMap = new Map(); } /** * Triggers an event with the given name and specified argument. - * @param {string} eventName The string representing the event's name. - * @param {*} [details] The argument passed to the callback functions. + * @param {TEventName} eventName The string representing the event's name. + * @param {unknown} [details] The argument passed to the callback functions. * @returns {boolean} `true` if any callbacks were registered, `false` otherwise. */ trigger(eventName, details) { @@ -413,8 +406,8 @@ export class EventDispatcher { /** * Adds a single event listener to a specific event. - * @param {string} eventName The string representing the event's name. - * @param {Function} callback The event listener callback to add. + * @param {TEventName} eventName The string representing the event's name. + * @param {(details: import('core').SafeAny) => void} callback The event listener callback to add. */ on(eventName, callback) { let callbacks = this._eventMap.get(eventName); @@ -427,8 +420,8 @@ export class EventDispatcher { /** * Removes a single event listener from a specific event. - * @param {string} eventName The string representing the event's name. - * @param {Function} callback The event listener callback to add. + * @param {TEventName} eventName The string representing the event's name. + * @param {(details: import('core').SafeAny) => void} callback The event listener callback to add. * @returns {boolean} `true` if the callback was removed, `false` otherwise. */ off(eventName, callback) { @@ -450,7 +443,7 @@ export class EventDispatcher { /** * Checks if an event has any listeners. - * @param {string} eventName The string representing the event's name. + * @param {TEventName} eventName The string representing the event's name. * @returns {boolean} `true` if the event has listeners, `false` otherwise. */ hasListeners(eventName) { @@ -467,6 +460,7 @@ export class EventListenerCollection { * Creates a new instance. */ constructor() { + /** @type {import('event-listener-collection').EventListenerDetails[]} */ this._eventListeners = []; } @@ -479,50 +473,40 @@ export class EventListenerCollection { } /** - * Adds an event listener of a generic type. - * @param {string} type The type of event listener, which can be 'addEventListener', 'addListener', or 'on'. - * @param {object} object The object to add the event listener to. - * @param {...*} args The argument array passed to the object's event listener adding function. - * @returns {void} - * @throws An error if type is not an expected value. - */ - addGeneric(type, object, ...args) { - switch (type) { - case 'addEventListener': return this.addEventListener(object, ...args); - case 'addListener': return this.addListener(object, ...args); - case 'on': return this.on(object, ...args); - default: throw new Error(`Invalid type: ${type}`); - } - } - - /** * Adds an event listener using `object.addEventListener`. The listener will later be removed using `object.removeEventListener`. - * @param {object} object The object to add the event listener to. - * @param {...*} args The argument array passed to the `addEventListener`/`removeEventListener` functions. + * @param {import('event-listener-collection').EventTarget} target The object to add the event listener to. + * @param {string} type The name of the event. + * @param {EventListener | EventListenerObject | import('event-listener-collection').EventListenerFunction} listener The callback listener. + * @param {AddEventListenerOptions | boolean} [options] Options for the event. */ - addEventListener(object, ...args) { - object.addEventListener(...args); - this._eventListeners.push(['removeEventListener', object, ...args]); + addEventListener(target, type, listener, options) { + target.addEventListener(type, listener, options); + this._eventListeners.push({type: 'removeEventListener', target, eventName: type, listener, options}); } /** * Adds an event listener using `object.addListener`. The listener will later be removed using `object.removeListener`. - * @param {object} object The object to add the event listener to. - * @param {...*} args The argument array passed to the `addListener`/`removeListener` function. + * @template {import('event-listener-collection').EventListenerFunction} TCallback + * @template [TArgs=unknown] + * @param {import('event-listener-collection').ExtensionEvent<TCallback, TArgs>} target The object to add the event listener to. + * @param {TCallback} callback The callback. + * @param {TArgs[]} args The extra argument array passed to the `addListener`/`removeListener` function. */ - addListener(object, ...args) { - object.addListener(...args); - this._eventListeners.push(['removeListener', object, ...args]); + addListener(target, callback, ...args) { + target.addListener(callback, ...args); + this._eventListeners.push({type: 'removeListener', target, callback, args}); } /** * Adds an event listener using `object.on`. The listener will later be removed using `object.off`. - * @param {object} object The object to add the event listener to. - * @param {...*} args The argument array passed to the `on`/`off` function. + * @template {string} TEventName + * @param {EventDispatcher<TEventName>} target The object to add the event listener to. + * @param {TEventName} eventName The string representing the event's name. + * @param {(details: import('core').SafeAny) => void} callback The event listener callback to add. */ - on(object, ...args) { - object.on(...args); - this._eventListeners.push(['off', object, ...args]); + on(target, eventName, callback) { + target.on(eventName, callback); + this._eventListeners.push({type: 'off', eventName, target, callback}); } /** @@ -530,16 +514,16 @@ export class EventListenerCollection { */ removeAllEventListeners() { if (this._eventListeners.length === 0) { return; } - for (const [removeFunctionName, object, ...args] of this._eventListeners) { - switch (removeFunctionName) { + for (const item of this._eventListeners) { + switch (item.type) { case 'removeEventListener': - object.removeEventListener(...args); + item.target.removeEventListener(item.eventName, item.listener, item.options); break; case 'removeListener': - object.removeListener(...args); + item.target.removeListener(item.callback, ...item.args); break; case 'off': - object.off(...args); + item.target.off(item.eventName, item.callback); break; } } @@ -550,23 +534,28 @@ export class EventListenerCollection { /** * Class representing a generic value with an override stack. * Changes can be observed by listening to the 'change' event. + * @template T + * @augments EventDispatcher<import('dynamic-property').EventType> */ export class DynamicProperty extends EventDispatcher { /** * Creates a new instance with the specified value. - * @param {*} value The value to assign. + * @param {T} value The value to assign. */ constructor(value) { super(); + /** @type {T} */ this._value = value; + /** @type {T} */ this._defaultValue = value; + /** @type {{value: T, priority: number, token: string}[]} */ this._overrides = []; } /** * Gets the default value for the property, which is assigned to the * public value property when no overrides are present. - * @type {*} + * @type {T} */ get defaultValue() { return this._defaultValue; @@ -576,7 +565,7 @@ export class DynamicProperty extends EventDispatcher { * Assigns the default value for the property. If no overrides are present * and if the value is different than the current default value, * the 'change' event will be triggered. - * @param {*} value The value to assign. + * @param {T} value The value to assign. */ set defaultValue(value) { this._defaultValue = value; @@ -585,7 +574,7 @@ export class DynamicProperty extends EventDispatcher { /** * Gets the current value for the property, taking any overrides into account. - * @type {*} + * @type {T} */ get value() { return this._value; @@ -593,7 +582,7 @@ export class DynamicProperty extends EventDispatcher { /** * Gets the number of overrides added to the property. - * @type {*} + * @type {number} */ get overrideCount() { return this._overrides.length; @@ -606,9 +595,9 @@ export class DynamicProperty extends EventDispatcher { * If the newly added override has the highest priority of all overrides * and if the override value is different from the current value, * the 'change' event will be fired. - * @param {*} value The override value to assign. + * @param {T} value The override value to assign. * @param {number} [priority] The priority value to use, as a number. - * @returns {string} A string token which can be passed to the clearOverride function + * @returns {import('core').TokenString} A string token which can be passed to the clearOverride function * to remove the override. */ setOverride(value, priority=0) { @@ -627,7 +616,7 @@ export class DynamicProperty extends EventDispatcher { * Removes a specific override value. If the removed override * had the highest priority, and the new value is different from * the previous value, the 'change' event will be fired. - * @param {string} token The token for the corresponding override which is to be removed. + * @param {import('core').TokenString} token The token for the corresponding override which is to be removed. * @returns {boolean} `true` if an override was returned, `false` otherwise. */ clearOverride(token) { @@ -649,13 +638,14 @@ export class DynamicProperty extends EventDispatcher { const value = this._overrides.length > 0 ? this._overrides[0].value : this._defaultValue; if (this._value === value) { return; } this._value = value; - this.trigger('change', {value}); + this.trigger('change', /** @type {import('dynamic-property').ChangeEventDetails<T>} */ ({value})); } } /** * This class handles logging of messages to the console and triggering * an event for log calls. + * @augments EventDispatcher<import('log').LoggerEventType> */ export class Logger extends EventDispatcher { /** @@ -663,7 +653,8 @@ export class Logger extends EventDispatcher { */ constructor() { super(); - this._extensionName = 'Yomichan'; + /** @type {string} */ + this._extensionName = 'Yomitan'; try { const {name, version} = chrome.runtime.getManifest(); this._extensionName = `${name} ${version}`; @@ -674,13 +665,13 @@ export class Logger extends EventDispatcher { /** * Logs a generic error. This will trigger the 'log' event with the same arguments as the function invocation. - * @param {Error|object|*} error The error to log. This is typically an `Error` or `Error`-like object. - * @param {string} level The level to log at. Values include `'info'`, `'debug'`, `'warn'`, and `'error'`. + * @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object. + * @param {import('log').LogLevel} level The level to log at. Values include `'info'`, `'debug'`, `'warn'`, and `'error'`. * Other values will be logged at a non-error level. - * @param {?object} [context] An optional context object for the error which should typically include a `url` field. + * @param {?import('log').LogContext} [context] An optional context object for the error which should typically include a `url` field. */ log(error, level, context=null) { - if (!isObject(context)) { + if (typeof context !== 'object' || context === null) { context = {url: location.href}; } @@ -689,7 +680,11 @@ export class Logger extends EventDispatcher { if (typeof error === 'string') { errorString = error; } else { - errorString = error.toString(); + errorString = ( + typeof error === 'object' && error !== null ? + error.toString() : + `${error}` + ); if (/^\[object \w+\]$/.test(errorString)) { errorString = JSON.stringify(error); } @@ -700,14 +695,20 @@ export class Logger extends EventDispatcher { let errorStack; try { - errorStack = (typeof error.stack === 'string' ? error.stack.trimRight() : ''); + errorStack = ( + error instanceof Error ? + (typeof error.stack === 'string' ? error.stack.trimEnd() : '') : + '' + ); } catch (e) { errorStack = ''; } let errorData; try { - errorData = error.data; + if (error instanceof ExtensionError) { + errorData = error.data; + } } catch (e) { // NOP } @@ -739,8 +740,8 @@ export class Logger extends EventDispatcher { /** * Logs a warning. This function invokes `log` internally. - * @param {Error|object|*} error The error to log. This is typically an `Error` or `Error`-like object. - * @param {?object} context An optional context object for the error which should typically include a `url` field. + * @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object. + * @param {?import('log').LogContext} context An optional context object for the error which should typically include a `url` field. */ warn(error, context=null) { this.log(error, 'warn', context); @@ -748,8 +749,8 @@ export class Logger extends EventDispatcher { /** * Logs an error. This function invokes `log` internally. - * @param {Error|object|*} error The error to log. This is typically an `Error` or `Error`-like object. - * @param {?object} context An optional context object for the error which should typically include a `url` field. + * @param {unknown} error The error to log. This is typically an `Error` or `Error`-like object. + * @param {?import('log').LogContext} context An optional context object for the error which should typically include a `url` field. */ error(error, context=null) { this.log(error, 'error', context); diff --git a/ext/js/core/extension-error.js b/ext/js/core/extension-error.js new file mode 100644 index 00000000..f1485a74 --- /dev/null +++ b/ext/js/core/extension-error.js @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2023 Yomitan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/** + * Custom error class for the extension which can contain extra data. + * This works around an issue where assigning the `DOMException.data` field can fail on Firefox. + * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1776555 + */ +export class ExtensionError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message); + /** @type {unknown} */ + this._data = void 0; + } + + /** @type {unknown} */ + get data() { return this._data; } + set data(value) { this._data = value; } + + /** + * Converts an `Error` object to a serializable JSON object. + * @param {unknown} error An error object to convert. + * @returns {import('core').SerializedError} A simple object which can be serialized by `JSON.stringify()`. + */ + static serialize(error) { + try { + if (typeof error === 'object' && error !== null) { + const {name, message, stack} = /** @type {import('core').SerializableObject} */ (error); + /** @type {import('core').SerializedError1} */ + const result = { + name: typeof name === 'string' ? name : '', + message: typeof message === 'string' ? message : '', + stack: typeof stack === 'string' ? stack : '' + }; + if (error instanceof ExtensionError) { + result.data = error.data; + } + return result; + } + } catch (e) { + // NOP + } + return /** @type {import('core').SerializedError2} */ ({ + value: error, + hasValue: true + }); + } + + /** + * Converts a serialized error into a standard `Error` object. + * @param {import('core').SerializedError} serializedError A simple object which was initially generated by the `serialize` function. + * @returns {ExtensionError} A new `Error` instance. + */ + static deserialize(serializedError) { + if (serializedError.hasValue) { + const {value} = serializedError; + return new ExtensionError(`Error of type ${typeof value}: ${value}`); + } + const {message, name, stack, data} = serializedError; + const error = new ExtensionError(message); + error.name = name; + error.stack = stack; + if (typeof data !== 'undefined') { + error.data = data; + } + return error; + } +} diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js index 398036c0..4920db39 100644 --- a/ext/js/data/anki-note-builder.js +++ b/ext/js/data/anki-note-builder.js @@ -16,20 +16,33 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deferPromise, deserializeError} from '../core.js'; +import {deferPromise} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {TemplateRendererProxy} from '../templates/template-renderer-proxy.js'; import {yomitan} from '../yomitan.js'; import {AnkiUtil} from './anki-util.js'; export class AnkiNoteBuilder { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil}} details + */ constructor({japaneseUtil}) { + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {RegExp} */ this._markerPattern = AnkiUtil.cloneFieldMarkerPattern(true); + /** @type {TemplateRendererProxy} */ this._templateRenderer = new TemplateRendererProxy(); + /** @type {import('anki-note-builder').BatchedRequestGroup[]} */ this._batchedRequests = []; + /** @type {boolean} */ this._batchedRequestsQueued = false; } + /** + * @param {import('anki-note-builder').CreateNoteDetails} details + * @returns {Promise<import('anki-note-builder').CreateNoteResult>} + */ async createNote({ dictionaryEntry, mode, @@ -56,16 +69,15 @@ export class AnkiNoteBuilder { duplicateScopeCheckChildren = true; } + /** @type {Error[]} */ const allErrors = []; let media; if (requirements.length > 0 && mediaOptions !== null) { let errors; ({media, errors} = await this._injectMedia(dictionaryEntry, requirements, mediaOptions)); for (const error of errors) { - allErrors.push(deserializeError(error)); + allErrors.push(ExtensionError.deserialize(error)); } - } else { - media = {}; } const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media); @@ -77,6 +89,7 @@ export class AnkiNoteBuilder { const formattedFieldValues = await Promise.all(formattedFieldValuePromises); const uniqueRequirements = new Map(); + /** @type {import('anki').NoteFields} */ const noteFields = {}; for (let i = 0, ii = fields.length; i < ii; ++i) { const fieldName = fields[i][0]; @@ -90,6 +103,7 @@ export class AnkiNoteBuilder { } } + /** @type {import('anki').Note} */ const note = { fields: noteFields, tags, @@ -108,6 +122,10 @@ export class AnkiNoteBuilder { return {note, errors: allErrors, requirements: [...uniqueRequirements.values()]}; } + /** + * @param {import('anki-note-builder').GetRenderingDataDetails} details + * @returns {Promise<import('anki-templates').NoteData>} + */ async getRenderingData({ dictionaryEntry, mode, @@ -115,12 +133,16 @@ export class AnkiNoteBuilder { resultOutputMode='split', glossaryLayoutMode='default', compactTags=false, - marker=null + marker }) { - const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, {}); + const commonData = this._createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, void 0); return await this._templateRenderer.getModifiedData({marker, commonData}, 'ankiNote'); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('api').InjectAnkiNoteMediaDefinitionDetails} + */ getDictionaryEntryDetailsForNote(dictionaryEntry) { const {type} = dictionaryEntry; if (type === 'kanji') { @@ -150,6 +172,16 @@ export class AnkiNoteBuilder { // Private + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').CreateMode} mode + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @param {import('settings').GlossaryLayoutMode} glossaryLayoutMode + * @param {boolean} compactTags + * @param {import('anki-templates').Media|undefined} media + * @returns {import('anki-note-builder').CommonData} + */ _createData(dictionaryEntry, mode, context, resultOutputMode, glossaryLayoutMode, compactTags, media) { return { dictionaryEntry, @@ -162,16 +194,25 @@ export class AnkiNoteBuilder { }; } + /** + * @param {string} field + * @param {import('anki-note-builder').CommonData} commonData + * @param {string} template + * @returns {Promise<{value: string, errors: ExtensionError[], requirements: import('anki-note-builder').Requirement[]}>} + */ async _formatField(field, commonData, template) { + /** @type {ExtensionError[]} */ const errors = []; + /** @type {import('anki-note-builder').Requirement[]} */ const requirements = []; - const value = await this._stringReplaceAsync(field, this._markerPattern, async (g0, marker) => { + const value = await this._stringReplaceAsync(field, this._markerPattern, async (match) => { + const marker = match[1]; try { const {result, requirements: fieldRequirements} = await this._renderTemplateBatched(template, commonData, marker); requirements.push(...fieldRequirements); return result; } catch (e) { - const error = new Error(`Template render error for {${marker}}`); + const error = new ExtensionError(`Template render error for {${marker}}`); error.data = {error: e}; errors.push(error); return `{${marker}-render-error}`; @@ -180,12 +221,19 @@ export class AnkiNoteBuilder { return {value, errors, requirements}; } + /** + * @param {string} str + * @param {RegExp} regex + * @param {(match: RegExpExecArray, index: number, str: string) => (string|Promise<string>)} replacer + * @returns {Promise<string>} + */ async _stringReplaceAsync(str, regex, replacer) { let match; let index = 0; + /** @type {(Promise<string>|string)[]} */ const parts = []; while ((match = regex.exec(str)) !== null) { - parts.push(str.substring(index, match.index), replacer(...match, match.index, str)); + parts.push(str.substring(index, match.index), replacer(match, match.index, str)); index = regex.lastIndex; } if (parts.length === 0) { @@ -195,6 +243,10 @@ export class AnkiNoteBuilder { return (await Promise.all(parts)).join(''); } + /** + * @param {string} template + * @returns {import('anki-note-builder').BatchedRequestGroup} + */ _getBatchedTemplateGroup(template) { for (const item of this._batchedRequests) { if (item.template === template) { @@ -207,7 +259,14 @@ export class AnkiNoteBuilder { return result; } + /** + * @param {string} template + * @param {import('anki-note-builder').CommonData} commonData + * @param {string} marker + * @returns {Promise<import('template-renderer').RenderResult>} + */ _renderTemplateBatched(template, commonData, marker) { + /** @type {import('core').DeferredPromiseDetails<import('template-renderer').RenderResult>} */ const {promise, resolve, reject} = deferPromise(); const {commonDataRequestsMap} = this._getBatchedTemplateGroup(template); let requests = commonDataRequestsMap.get(commonData); @@ -220,6 +279,9 @@ export class AnkiNoteBuilder { return promise; } + /** + * @returns {void} + */ _runBatchedRequestsDelayed() { if (this._batchedRequestsQueued) { return; } this._batchedRequestsQueued = true; @@ -229,20 +291,30 @@ export class AnkiNoteBuilder { }); } + /** + * @returns {void} + */ _runBatchedRequests() { if (this._batchedRequests.length === 0) { return; } const allRequests = []; + /** @type {import('template-renderer').RenderMultiItem[]} */ const items = []; for (const {template, commonDataRequestsMap} of this._batchedRequests) { + /** @type {import('template-renderer').RenderMultiTemplateItem[]} */ const templateItems = []; for (const [commonData, requests] of commonDataRequestsMap.entries()) { + /** @type {import('template-renderer').PartialOrCompositeRenderData[]} */ const datas = []; for (const {marker} of requests) { - datas.push(marker); + datas.push({marker}); } allRequests.push(...requests); - templateItems.push({type: 'ankiNote', commonData, datas}); + templateItems.push({ + type: /** @type {import('anki-templates').RenderMode} */ ('ankiNote'), + commonData, + datas + }); } items.push({template, templateItems}); } @@ -252,6 +324,10 @@ export class AnkiNoteBuilder { this._resolveBatchedRequests(items, allRequests); } + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @param {import('anki-note-builder').BatchedRequestData[]} requests + */ async _resolveBatchedRequests(items, requests) { let responses; try { @@ -269,7 +345,7 @@ export class AnkiNoteBuilder { const response = responses[i]; const {error} = response; if (typeof error !== 'undefined') { - throw deserializeError(error); + throw ExtensionError.deserialize(error); } else { request.resolve(response.result); } @@ -279,6 +355,12 @@ export class AnkiNoteBuilder { } } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-note-builder').Requirement[]} requirements + * @param {import('anki-note-builder').MediaOptions} mediaOptions + * @returns {Promise<{media: import('anki-templates').Media, errors: import('core').SerializedError[]}>} + */ async _injectMedia(dictionaryEntry, requirements, mediaOptions) { const timestamp = Date.now(); @@ -288,7 +370,9 @@ export class AnkiNoteBuilder { let injectClipboardImage = false; let injectClipboardText = false; let injectSelectionText = false; + /** @type {import('anki-note-builder').TextFuriganaDetails[]} */ const textFuriganaDetails = []; + /** @type {import('api').InjectAnkiNoteMediaDictionaryMediaDetails[]} */ const dictionaryMediaDetails = []; for (const requirement of requirements) { const {type} = requirement; @@ -315,8 +399,11 @@ export class AnkiNoteBuilder { // Generate request data const dictionaryEntryDetails = this.getDictionaryEntryDetailsForNote(dictionaryEntry); + /** @type {?import('api').InjectAnkiNoteMediaAudioDetails} */ let audioDetails = null; + /** @type {?import('api').InjectAnkiNoteMediaScreenshotDetails} */ let screenshotDetails = null; + /** @type {import('api').InjectAnkiNoteMediaClipboardDetails} */ const clipboardDetails = {image: injectClipboardImage, text: injectClipboardText}; if (injectAudio && dictionaryEntryDetails.type !== 'kanji') { const audioOptions = mediaOptions.audio; @@ -357,6 +444,7 @@ export class AnkiNoteBuilder { const textFurigana = textFuriganaPromise !== null ? await textFuriganaPromise : []; // Format results + /** @type {import('anki-templates').DictionaryMedia} */ const dictionaryMedia = {}; for (const {dictionary, path, fileName} of dictionaryMediaArray) { if (fileName === null) { continue; } @@ -368,21 +456,31 @@ export class AnkiNoteBuilder { dictionaryMedia2[path] = {value: fileName}; } const media = { - audio: (typeof audioFileName === 'string' ? {value: audioFileName} : null), - screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : null), - clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : null), - clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : null), - selectionText: (typeof selectionText === 'string' ? {value: selectionText} : null), + audio: (typeof audioFileName === 'string' ? {value: audioFileName} : void 0), + screenshot: (typeof screenshotFileName === 'string' ? {value: screenshotFileName} : void 0), + clipboardImage: (typeof clipboardImageFileName === 'string' ? {value: clipboardImageFileName} : void 0), + clipboardText: (typeof clipboardText === 'string' ? {value: clipboardText} : void 0), + selectionText: (typeof selectionText === 'string' ? {value: selectionText} : void 0), textFurigana, dictionaryMedia }; return {media, errors}; } + /** + * @returns {string} + */ _getSelectionText() { - return document.getSelection().toString(); + const selection = document.getSelection(); + return selection !== null ? selection.toString() : ''; } + /** + * @param {import('anki-note-builder').TextFuriganaDetails[]} entries + * @param {import('settings').OptionsContext} optionsContext + * @param {number} scanLength + * @returns {Promise<import('anki-templates').TextFuriganaSegment[]>} + */ async _getTextFurigana(entries, optionsContext, scanLength) { const results = []; for (const {text, readingMode} of entries) { @@ -401,6 +499,11 @@ export class AnkiNoteBuilder { return results; } + /** + * @param {import('api').ParseTextLine[]} data + * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode + * @returns {string} + */ _createFuriganaHtml(data, readingMode) { let result = ''; for (const term of data) { @@ -418,6 +521,11 @@ export class AnkiNoteBuilder { return result; } + /** + * @param {string} reading + * @param {?import('anki-templates').TextFuriganaReadingMode} readingMode + * @returns {string} + */ _convertReading(reading, readingMode) { switch (readingMode) { case 'hiragana': diff --git a/ext/js/data/anki-util.js b/ext/js/data/anki-util.js index c08b562e..1d5272a6 100644 --- a/ext/js/data/anki-util.js +++ b/ext/js/data/anki-util.js @@ -71,7 +71,7 @@ export class AnkiUtil { /** * Checks whether or not a note object is valid. - * @param {*} note A note object to check. + * @param {import('anki').Note} note A note object to check. * @returns {boolean} `true` if the note is valid, `false` otherwise. */ static isNoteDataValid(note) { diff --git a/ext/js/data/database.js b/ext/js/data/database.js index 8e818d8b..026945ca 100644 --- a/ext/js/data/database.js +++ b/ext/js/data/database.js @@ -16,12 +16,22 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +/** + * @template {string} TObjectStoreName + */ export class Database { constructor() { + /** @type {?IDBDatabase} */ this._db = null; + /** @type {boolean} */ this._isOpening = false; } + /** + * @param {string} databaseName + * @param {number} version + * @param {import('database').StructureDefinition<TObjectStoreName>[]} structure + */ async open(databaseName, version, structure) { if (this._db !== null) { throw new Error('Database already open'); @@ -40,6 +50,9 @@ export class Database { } } + /** + * @throws {Error} + */ close() { if (this._db === null) { throw new Error('Database is not open'); @@ -49,14 +62,26 @@ export class Database { this._db = null; } + /** + * @returns {boolean} + */ isOpening() { return this._isOpening; } + /** + * @returns {boolean} + */ isOpen() { return this._db !== null; } + /** + * @param {string[]} storeNames + * @param {IDBTransactionMode} mode + * @returns {IDBTransaction} + * @throws {Error} + */ transaction(storeNames, mode) { if (this._db === null) { throw new Error(this._isOpening ? 'Database not ready' : 'Database not open'); @@ -64,6 +89,13 @@ export class Database { return this._db.transaction(storeNames, mode); } + /** + * @param {TObjectStoreName} objectStoreName + * @param {unknown[]} items + * @param {number} start + * @param {number} count + * @returns {Promise<void>} + */ bulkAdd(objectStoreName, items, start, count) { return new Promise((resolve, reject) => { if (start + count > items.length) { @@ -84,6 +116,15 @@ export class Database { }); } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onError + * @param {TData} data + */ getAll(objectStoreOrIndex, query, onSuccess, onError, data) { if (typeof objectStoreOrIndex.getAll === 'function') { this._getAllFast(objectStoreOrIndex, query, onSuccess, onError, data); @@ -92,6 +133,12 @@ export class Database { } } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ getAllKeys(objectStoreOrIndex, query, onSuccess, onError) { if (typeof objectStoreOrIndex.getAllKeys === 'function') { this._getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError); @@ -100,6 +147,18 @@ export class Database { } } + /** + * @template TPredicateArg + * @template [TResult=unknown] + * @template [TResultDefault=unknown] + * @param {TObjectStoreName} objectStoreName + * @param {?string} indexName + * @param {?IDBValidKey|IDBKeyRange} query + * @param {?((value: TResult|TResultDefault, predicateArg: TPredicateArg) => boolean)} predicate + * @param {TPredicateArg} predicateArg + * @param {TResultDefault} defaultValue + * @returns {Promise<TResult|TResultDefault>} + */ find(objectStoreName, indexName, query, predicate, predicateArg, defaultValue) { return new Promise((resolve, reject) => { const transaction = this.transaction([objectStoreName], 'readonly'); @@ -109,12 +168,26 @@ export class Database { }); } + /** + * @template TData + * @template TPredicateArg + * @template [TResult=unknown] + * @template [TResultDefault=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(value: TResult|TResultDefault, data: TData) => void} resolve + * @param {(reason: unknown, data: TData) => void} reject + * @param {TData} data + * @param {?((value: TResult, predicateArg: TPredicateArg) => boolean)} predicate + * @param {TPredicateArg} predicateArg + * @param {TResultDefault} defaultValue + */ findFirst(objectStoreOrIndex, query, resolve, reject, data, predicate, predicateArg, defaultValue) { const noPredicate = (typeof predicate !== 'function'); const request = objectStoreOrIndex.openCursor(query, 'next'); - request.onerror = (e) => reject(e.target.error, data); + request.onerror = (e) => reject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; if (cursor) { const {value} = cursor; if (noPredicate || predicate(value, predicateArg)) { @@ -128,19 +201,33 @@ export class Database { }; } + /** + * @param {import('database').CountTarget[]} targets + * @param {(results: number[]) => void} resolve + * @param {(reason?: unknown) => void} reject + */ bulkCount(targets, resolve, reject) { const targetCount = targets.length; if (targetCount <= 0) { - resolve(); + resolve([]); return; } let completedCount = 0; + /** @type {number[]} */ const results = new Array(targetCount).fill(null); - const onError = (e) => reject(e.target.error); + /** + * @param {Event} e + * @returns {void} + */ + const onError = (e) => reject(/** @type {IDBRequest<number>} */ (e.target).error); + /** + * @param {Event} e + * @param {number} index + */ const onSuccess = (e, index) => { - const count = e.target.result; + const count = /** @type {IDBRequest<number>} */ (e.target).result; results[index] = count; if (++completedCount >= targetCount) { resolve(results); @@ -156,6 +243,11 @@ export class Database { } } + /** + * @param {TObjectStoreName} objectStoreName + * @param {IDBValidKey|IDBKeyRange} key + * @returns {Promise<void>} + */ delete(objectStoreName, key) { return new Promise((resolve, reject) => { const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); @@ -165,12 +257,23 @@ export class Database { }); } + /** + * @param {TObjectStoreName} objectStoreName + * @param {?string} indexName + * @param {IDBKeyRange} query + * @param {?(keys: IDBValidKey[]) => IDBValidKey[]} filterKeys + * @param {?(completedCount: number, totalCount: number) => void} onProgress + * @returns {Promise<void>} + */ bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) { return new Promise((resolve, reject) => { const transaction = this._readWriteTransaction([objectStoreName], resolve, reject); const objectStore = transaction.objectStore(objectStoreName); const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore; + /** + * @param {IDBValidKey[]} keys + */ const onGetKeys = (keys) => { try { if (typeof filterKeys === 'function') { @@ -187,10 +290,14 @@ export class Database { }); } + /** + * @param {string} databaseName + * @returns {Promise<void>} + */ static deleteDatabase(databaseName) { return new Promise((resolve, reject) => { const request = indexedDB.deleteDatabase(databaseName); - request.onerror = (e) => reject(e.target.error); + request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); request.onsuccess = () => resolve(); request.onblocked = () => reject(new Error('Database deletion blocked')); }); @@ -198,24 +305,37 @@ export class Database { // Private + /** + * @param {string} name + * @param {number} version + * @param {import('database').UpdateFunction} onUpgradeNeeded + * @returns {Promise<IDBDatabase>} + */ _open(name, version, onUpgradeNeeded) { return new Promise((resolve, reject) => { const request = indexedDB.open(name, version); request.onupgradeneeded = (event) => { try { - request.transaction.onerror = (e) => reject(e.target.error); - onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion); + const transaction = /** @type {IDBTransaction} */ (request.transaction); + transaction.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); + onUpgradeNeeded(request.result, transaction, event.oldVersion, event.newVersion); } catch (e) { reject(e); } }; - request.onerror = (e) => reject(e.target.error); + request.onerror = (e) => reject(/** @type {IDBRequest} */ (e.target).error); request.onsuccess = () => resolve(request.result); }); } + /** + * @param {IDBDatabase} db + * @param {IDBTransaction} transaction + * @param {number} oldVersion + * @param {import('database').StructureDefinition<TObjectStoreName>[]} upgrades + */ _upgrade(db, transaction, oldVersion, upgrades) { for (const {version, stores} of upgrades) { if (oldVersion >= version) { continue; } @@ -238,6 +358,11 @@ export class Database { } } + /** + * @param {DOMStringList} list + * @param {string} value + * @returns {boolean} + */ _listContains(list, value) { for (let i = 0, ii = list.length; i < ii; ++i) { if (list[i] === value) { return true; } @@ -245,18 +370,37 @@ export class Database { return false; } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onReject + * @param {TData} data + */ _getAllFast(objectStoreOrIndex, query, onSuccess, onReject, data) { const request = objectStoreOrIndex.getAll(query); - request.onerror = (e) => onReject(e.target.error, data); - request.onsuccess = (e) => onSuccess(e.target.result, data); + request.onerror = (e) => onReject(/** @type {IDBRequest<import('core').SafeAny[]>} */ (e.target).error, data); + request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<import('core').SafeAny[]>} */ (e.target).result, data); } + /** + * @template [TData=unknown] + * @template [TResult=unknown] + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {?IDBValidKey|IDBKeyRange} query + * @param {(results: TResult[], data: TData) => void} onSuccess + * @param {(reason: unknown, data: TData) => void} onReject + * @param {TData} data + */ _getAllUsingCursor(objectStoreOrIndex, query, onSuccess, onReject, data) { + /** @type {TResult[]} */ const results = []; const request = objectStoreOrIndex.openCursor(query, 'next'); - request.onerror = (e) => onReject(e.target.error, data); + request.onerror = (e) => onReject(/** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).error, data); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursorWithValue>} */ (e.target).result; if (cursor) { results.push(cursor.value); cursor.continue(); @@ -266,18 +410,31 @@ export class Database { }; } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ _getAllKeysFast(objectStoreOrIndex, query, onSuccess, onError) { const request = objectStoreOrIndex.getAllKeys(query); - request.onerror = (e) => onError(e.target.error); - request.onsuccess = (e) => onSuccess(e.target.result); + request.onerror = (e) => onError(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).error); + request.onsuccess = (e) => onSuccess(/** @type {IDBRequest<IDBValidKey[]>} */ (e.target).result); } + /** + * @param {IDBObjectStore|IDBIndex} objectStoreOrIndex + * @param {IDBValidKey|IDBKeyRange} query + * @param {(value: IDBValidKey[]) => void} onSuccess + * @param {(reason?: unknown) => void} onError + */ _getAllKeysUsingCursor(objectStoreOrIndex, query, onSuccess, onError) { + /** @type {IDBValidKey[]} */ const results = []; const request = objectStoreOrIndex.openKeyCursor(query, 'next'); - request.onerror = (e) => onError(e.target.error); + request.onerror = (e) => onError(/** @type {IDBRequest<?IDBCursor>} */ (e.target).error); request.onsuccess = (e) => { - const cursor = e.target.result; + const cursor = /** @type {IDBRequest<?IDBCursor>} */ (e.target).result; if (cursor) { results.push(cursor.primaryKey); cursor.continue(); @@ -287,6 +444,11 @@ export class Database { }; } + /** + * @param {IDBObjectStore} objectStore + * @param {IDBValidKey[]} keys + * @param {?(completedCount: number, totalCount: number) => void} onProgress + */ _bulkDeleteInternal(objectStore, keys, onProgress) { const count = keys.length; if (count === 0) { return; } @@ -295,7 +457,7 @@ export class Database { const onSuccess = () => { ++completedCount; try { - onProgress(completedCount, count); + /** @type {(completedCount: number, totalCount: number) => void}} */ (onProgress)(completedCount, count); } catch (e) { // NOP } @@ -310,9 +472,15 @@ export class Database { } } + /** + * @param {string[]} storeNames + * @param {() => void} resolve + * @param {(reason?: unknown) => void} reject + * @returns {IDBTransaction} + */ _readWriteTransaction(storeNames, resolve, reject) { const transaction = this.transaction(storeNames, 'readwrite'); - transaction.onerror = (e) => reject(e.target.error); + transaction.onerror = (e) => reject(/** @type {IDBTransaction} */ (e.target).error); transaction.onabort = () => reject(new Error('Transaction aborted')); transaction.oncomplete = () => resolve(); return transaction; diff --git a/ext/js/data/json-schema.js b/ext/js/data/json-schema.js index 93c8cd59..d63cfd1a 100644 --- a/ext/js/data/json-schema.js +++ b/ext/js/data/json-schema.js @@ -19,31 +19,70 @@ import {clone} from '../core.js'; import {CacheMap} from '../general/cache-map.js'; +export class JsonSchemaError extends Error { + /** + * @param {string} message + * @param {import('json-schema').ValueStackItem[]} valueStack + * @param {import('json-schema').SchemaStackItem[]} schemaStack + */ + constructor(message, valueStack, schemaStack) { + super(message); + /** @type {import('json-schema').ValueStackItem[]} */ + this._valueStack = valueStack; + /** @type {import('json-schema').SchemaStackItem[]} */ + this._schemaStack = schemaStack; + } + + /** @type {unknown|undefined} */ + get value() { return this._valueStack.length > 0 ? this._valueStack[this._valueStack.length - 1].value : void 0; } + + /** @type {import('json-schema').Schema|import('json-schema').Schema[]|undefined} */ + get schema() { return this._schemaStack.length > 0 ? this._schemaStack[this._schemaStack.length - 1].schema : void 0; } + + /** @type {import('json-schema').ValueStackItem[]} */ + get valueStack() { return this._valueStack; } + + /** @type {import('json-schema').SchemaStackItem[]} */ + get schemaStack() { return this._schemaStack; } +} + export class JsonSchema { + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').Schema} [rootSchema] + */ constructor(schema, rootSchema) { - this._schema = null; + /** @type {import('json-schema').Schema} */ this._startSchema = schema; + /** @type {import('json-schema').Schema} */ this._rootSchema = typeof rootSchema !== 'undefined' ? rootSchema : schema; + /** @type {?CacheMap<string, RegExp>} */ this._regexCache = null; + /** @type {?Map<string, {schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}>} */ this._refCache = null; + /** @type {import('json-schema').ValueStackItem[]} */ this._valueStack = []; + /** @type {import('json-schema').SchemaStackItem[]} */ this._schemaStack = []; + /** @type {?(jsonSchema: JsonSchema) => void} */ this._progress = null; + /** @type {number} */ this._progressCounter = 0; + /** @type {number} */ this._progressInterval = 1; - - this._schemaPush(null, null); - this._valuePush(null, null); } + /** @type {import('json-schema').Schema} */ get schema() { return this._startSchema; } + /** @type {import('json-schema').Schema} */ get rootSchema() { return this._rootSchema; } + /** @type {?(jsonSchema: JsonSchema) => void} */ get progress() { return this._progress; } @@ -52,6 +91,7 @@ export class JsonSchema { this._progress = value; } + /** @type {number} */ get progressInterval() { return this._progressInterval; } @@ -60,6 +100,10 @@ export class JsonSchema { this._progressInterval = value; } + /** + * @param {import('json-schema').Value} value + * @returns {import('json-schema').Value} + */ createProxy(value) { return ( typeof value === 'object' && value !== null ? @@ -68,6 +112,10 @@ export class JsonSchema { ); } + /** + * @param {unknown} value + * @returns {boolean} + */ isValid(value) { try { this.validate(value); @@ -77,123 +125,203 @@ export class JsonSchema { } } + /** + * @param {unknown} value + */ validate(value) { - this._schemaPush(this._startSchema, null); + const schema = this._startSchema; + this._schemaPush(schema, null); this._valuePush(value, null); try { - this._validate(value); + this._validate(schema, value); } finally { this._valuePop(); this._schemaPop(); } } + /** + * @param {unknown} [value] + * @returns {import('json-schema').Value} + */ getValidValueOrDefault(value) { - return this._getValidValueOrDefault(null, value, {schema: this._startSchema, path: null}); + const schema = this._startSchema; + return this._getValidValueOrDefault(schema, null, value, [{schema, path: null}]); } + /** + * @param {string} property + * @returns {?JsonSchema} + */ getObjectPropertySchema(property) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: propertySchema} = this._getObjectPropertySchemaInfo(schema2, property); + return propertySchema !== false ? new JsonSchema(propertySchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {number} index + * @returns {?JsonSchema} + */ getArrayItemSchema(index) { - const startSchemaInfo = this._getResolveSchemaInfo({schema: this._startSchema, path: null}); - this._schemaPush(startSchemaInfo.schema, startSchemaInfo.path); + const schema = this._startSchema; + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, [{schema, path: null}]); + this._schemaPushMultiple(stack); try { - const schemaInfo = this._getArrayItemSchemaInfo(index); - return schemaInfo !== null ? new JsonSchema(schemaInfo.schema, this._rootSchema) : null; + const {schema: itemSchema} = this._getArrayItemSchemaInfo(schema2, index); + return itemSchema !== false ? new JsonSchema(itemSchema, this._rootSchema) : null; } finally { - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } + /** + * @param {string} property + * @returns {boolean} + */ isObjectPropertyRequired(property) { - const {required} = this._startSchema; + const schema = this._startSchema; + if (typeof schema === 'boolean') { return false; } + const {required} = schema; return Array.isArray(required) && required.includes(property); } // Internal state functions for error construction and progress callback + /** + * @returns {import('json-schema').ValueStackItem[]} + */ getValueStack() { - const valueStack = []; - for (let i = 1, ii = this._valueStack.length; i < ii; ++i) { - const {value, path} = this._valueStack[i]; - valueStack.push({value, path}); + const result = []; + for (const {value, path} of this._valueStack) { + result.push({value, path}); } - return valueStack; + return result; } + /** + * @returns {import('json-schema').SchemaStackItem[]} + */ getSchemaStack() { - const schemaStack = []; - for (let i = 1, ii = this._schemaStack.length; i < ii; ++i) { - const {schema, path} = this._schemaStack[i]; - schemaStack.push({schema, path}); + const result = []; + for (const {schema, path} of this._schemaStack) { + result.push({schema, path}); } - return schemaStack; + return result; } + /** + * @returns {number} + */ getValueStackLength() { return this._valueStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').ValueStackItem} + */ getValueStackItem(index) { const {value, path} = this._valueStack[index + 1]; return {value, path}; } + /** + * @returns {number} + */ getSchemaStackLength() { return this._schemaStack.length - 1; } + /** + * @param {number} index + * @returns {import('json-schema').SchemaStackItem} + */ getSchemaStackItem(index) { const {schema, path} = this._schemaStack[index + 1]; return {schema, path}; } + /** + * @template T + * @param {T} value + * @returns {T} + */ + static clone(value) { + return clone(value); + } + // Stack + /** + * @param {unknown} value + * @param {string|number|null} path + */ _valuePush(value, path) { this._valueStack.push({value, path}); } + /** + * @returns {void} + */ _valuePop() { this._valueStack.pop(); } + /** + * @param {import('json-schema').Schema|import('json-schema').Schema[]} schema + * @param {string|number|null} path + */ _schemaPush(schema, path) { this._schemaStack.push({schema, path}); - this._schema = schema; } + /** + * @param {import('json-schema').SchemaStackItem[]} items + */ + _schemaPushMultiple(items) { + this._schemaStack.push(...items); + } + + /** + * @returns {void} + */ _schemaPop() { this._schemaStack.pop(); - this._schema = this._schemaStack[this._schemaStack.length - 1].schema; + } + + /** + * @param {number} count + */ + _schemaPopMultiple(count) { + for (let i = 0; i < count; ++i) { + this._schemaStack.pop(); + } } // Private + /** + * @param {string} message + * @returns {JsonSchemaError} + */ _createError(message) { const valueStack = this.getValueStack(); const schemaStack = this.getSchemaStack(); - const error = new Error(message); - error.value = valueStack[valueStack.length - 1].value; - error.schema = schemaStack[schemaStack.length - 1].schema; - error.valueStack = valueStack; - error.schemaStack = schemaStack; - return error; - } - - _isObject(value) { - return typeof value === 'object' && value !== null && !Array.isArray(value); + return new JsonSchemaError(message, valueStack, schemaStack); } + /** + * @param {string} pattern + * @param {string} flags + * @returns {RegExp} + */ _getRegex(pattern, flags) { if (this._regexCache === null) { this._regexCache = new CacheMap(100); @@ -208,81 +336,125 @@ export class JsonSchema { return regex; } - _getUnconstrainedSchema() { - return {}; - } - - _getObjectPropertySchemaInfo(property) { - const {properties} = this._schema; - if (this._isObject(properties)) { + /** + * @param {import('json-schema').Schema} schema + * @param {string} property + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getObjectPropertySchemaInfo(schema, property) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {properties} = schema; + if (typeof properties !== 'undefined' && Object.prototype.hasOwnProperty.call(properties, property)) { const propertySchema = properties[property]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['properties', property]}; + if (typeof propertySchema !== 'undefined') { + return { + schema: propertySchema, + stack: [ + {schema: properties, path: 'properties'}, + {schema: propertySchema, path: property} + ] + }; } } - - const {additionalProperties} = this._schema; - if (additionalProperties === false) { - return null; - } else if (this._isObject(additionalProperties)) { - return {schema: additionalProperties, path: 'additionalProperties'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getArrayItemSchemaInfo(index) { - const {items} = this._schema; - if (this._isObject(items)) { - return {schema: items, path: 'items'}; - } - if (Array.isArray(items)) { - if (index >= 0 && index < items.length) { - const propertySchema = items[index]; - if (this._isObject(propertySchema)) { - return {schema: propertySchema, path: ['items', index]}; + return this._getOptionalSchemaInfo(schema.additionalProperties, 'additionalProperties'); + } + + /** + * @param {import('json-schema').Schema} schema + * @param {number} index + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getArrayItemSchemaInfo(schema, index) { + if (typeof schema === 'boolean') { + return {schema, stack: [{schema, path: null}]}; + } + const {prefixItems} = schema; + if (typeof prefixItems !== 'undefined') { + if (index >= 0 && index < prefixItems.length) { + const itemSchema = prefixItems[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: prefixItems, path: 'prefixItems'}, + {schema: itemSchema, path: index} + ] + }; } } } - - const {additionalItems} = this._schema; - if (additionalItems === false) { - return null; - } else if (this._isObject(additionalItems)) { - return {schema: additionalItems, path: 'additionalItems'}; - } else { - const result = this._getUnconstrainedSchema(); - return {schema: result, path: null}; - } - } - - _getSchemaOrValueType(value) { - const {type} = this._schema; - - if (Array.isArray(type)) { - if (typeof value !== 'undefined') { - const valueType = this._getValueType(value); - if (type.indexOf(valueType) >= 0) { - return valueType; + const {items} = schema; + if (typeof items !== 'undefined') { + if (Array.isArray(items)) { // Legacy schema format + if (index >= 0 && index < items.length) { + const itemSchema = items[index]; + if (typeof itemSchema !== 'undefined') { + return { + schema: itemSchema, + stack: [ + {schema: items, path: 'items'}, + {schema: itemSchema, path: index} + ] + }; + } } + } else { + return { + schema: items, + stack: [{schema: items, path: 'items'}] + }; } - return null; } + return this._getOptionalSchemaInfo(schema.additionalItems, 'additionalItems'); + } - if (typeof type !== 'undefined') { return type; } - return (typeof value !== 'undefined') ? this._getValueType(value) : null; + /** + * @param {import('json-schema').Schema|undefined} schema + * @param {string|number|null} path + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getOptionalSchemaInfo(schema, path) { + switch (typeof schema) { + case 'boolean': + case 'object': + break; + default: + schema = true; + path = null; + break; + } + return {schema, stack: [{schema, path}]}; } + /** + * @param {unknown} value + * @returns {?import('json-schema').Type} + * @throws {Error} + */ _getValueType(value) { const type = typeof value; - if (type === 'object') { - if (value === null) { return 'null'; } - if (Array.isArray(value)) { return 'array'; } + switch (type) { + case 'object': + if (value === null) { return 'null'; } + if (Array.isArray(value)) { return 'array'; } + return 'object'; + case 'string': + case 'number': + case 'boolean': + return type; + default: + return null; } - return type; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} schemaTypes + * @returns {boolean} + */ _isValueTypeAny(value, type, schemaTypes) { if (typeof schemaTypes === 'string') { return this._isValueType(value, type, schemaTypes); @@ -297,13 +469,24 @@ export class JsonSchema { return true; } + /** + * @param {unknown} value + * @param {?import('json-schema').Type} type + * @param {import('json-schema').Type} schemaType + * @returns {boolean} + */ _isValueType(value, type, schemaType) { return ( type === schemaType || - (schemaType === 'integer' && Math.floor(value) === value) + (schemaType === 'integer' && typeof value === 'number' && Math.floor(value) === value) ); } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value[]} valueList + * @returns {boolean} + */ _valuesAreEqualAny(value1, valueList) { for (const value2 of valueList) { if (this._valuesAreEqual(value1, value2)) { @@ -313,29 +496,45 @@ export class JsonSchema { return false; } + /** + * @param {unknown} value1 + * @param {import('json-schema').Value} value2 + * @returns {boolean} + */ _valuesAreEqual(value1, value2) { return value1 === value2; } - _getResolveSchemaInfo(schemaInfo) { - const ref = schemaInfo.schema.$ref; - if (typeof ref !== 'string') { return schemaInfo; } - - const {path: basePath} = schemaInfo; - const {schema, path} = this._getReference(ref); - if (Array.isArray(basePath)) { - path.unshift(...basePath); - } else { - path.unshift(basePath); + /** + * @param {import('json-schema').Schema} schema + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + */ + _getResolvedSchemaInfo(schema, stack) { + if (typeof schema !== 'boolean') { + const ref = schema.$ref; + if (typeof ref === 'string') { + const {schema: schema2, stack: stack2} = this._getReference(ref); + return { + schema: schema2, + stack: [...stack, ...stack2] + }; + } } - return {schema, path}; + return {schema, stack}; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReference(ref) { if (!ref.startsWith('#/')) { throw this._createError(`Unsupported reference path: ${ref}`); } + /** @type {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}|undefined} */ let info; if (this._refCache !== null) { info = this._refCache.get(ref); @@ -348,12 +547,20 @@ export class JsonSchema { this._refCache.set(ref, info); } - return {schema: info.schema, path: [...info.path]}; + info.stack = this._copySchemaStack(info.stack); + return info; } + /** + * @param {string} ref + * @returns {{schema: import('json-schema').Schema, stack: import('json-schema').SchemaStackItem[]}} + * @throws {Error} + */ _getReferenceUncached(ref) { + /** @type {Set<string>} */ const visited = new Set(); - const path = []; + /** @type {import('json-schema').SchemaStackItem[]} */ + const stack = []; while (true) { if (visited.has(ref)) { throw this._createError(`Recursive reference: ${ref}`); @@ -362,106 +569,139 @@ export class JsonSchema { const pathParts = ref.substring(2).split('/'); let schema = this._rootSchema; - try { - for (const pathPart of pathParts) { - schema = schema[pathPart]; + stack.push({schema, path: null}); + for (const pathPart of pathParts) { + if (!(typeof schema === 'object' && schema !== null && Object.prototype.hasOwnProperty.call(schema, pathPart))) { + throw this._createError(`Invalid reference: ${ref}`); } - } catch (e) { - throw this._createError(`Invalid reference: ${ref}`); + const schemaNext = /** @type {import('core').UnknownObject} */ (schema)[pathPart]; + if (!(typeof schemaNext === 'boolean' || (typeof schemaNext === 'object' && schemaNext !== null))) { + throw this._createError(`Invalid reference: ${ref}`); + } + schema = schemaNext; + stack.push({schema, path: pathPart}); } - if (!this._isObject(schema)) { + if (Array.isArray(schema)) { throw this._createError(`Invalid reference: ${ref}`); } - path.push(null, ...pathParts); - - ref = schema.$ref; - if (typeof ref !== 'string') { - return {schema, path}; + const refNext = typeof schema === 'object' && schema !== null ? schema.$ref : void 0; + if (typeof refNext !== 'string') { + return {schema, stack}; } + ref = refNext; + } + } + + /** + * @param {import('json-schema').SchemaStackItem[]} schemaStack + * @returns {import('json-schema').SchemaStackItem[]} + */ + _copySchemaStack(schemaStack) { + /** @type {import('json-schema').SchemaStackItem[]} */ + const results = []; + for (const {schema, path} of schemaStack) { + results.push({schema, path}); } + return results; } // Validation - _isValidCurrent(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @returns {boolean} + */ + _isValidCurrent(schema, value) { try { - this._validate(value); + this._validate(schema, value); return true; } catch (e) { return false; } } - _validate(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + */ + _validate(schema, value) { if (this._progress !== null) { const counter = (this._progressCounter + 1) % this._progressInterval; this._progressCounter = counter; if (counter === 0) { this._progress(this); } } - const ref = this._schema.$ref; - const schemaInfo = (typeof ref === 'string') ? this._getReference(ref) : null; - - if (schemaInfo === null) { - this._validateInner(value); - } else { - this._schemaPush(schemaInfo.schema, schemaInfo.path); - try { - this._validateInner(value); - } finally { - this._schemaPop(); - } - } - } - - _validateInner(value) { - this._validateSingleSchema(value); - this._validateConditional(value); - this._validateAllOf(value); - this._validateAnyOf(value); - this._validateOneOf(value); - this._validateNoneOf(value); - } - - _validateConditional(value) { - const ifSchema = this._schema.if; - if (!this._isObject(ifSchema)) { return; } + const {schema: schema2, stack} = this._getResolvedSchemaInfo(schema, []); + this._schemaPushMultiple(stack); + try { + this._validateInner(schema2, value); + } finally { + this._schemaPopMultiple(stack.length); + } + } + + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @throws {Error} + */ + _validateInner(schema, value) { + if (schema === true) { return; } + if (schema === false) { throw this._createError('False schema'); } + this._validateSingleSchema(schema, value); + this._validateConditional(schema, value); + this._validateAllOf(schema, value); + this._validateAnyOf(schema, value); + this._validateOneOf(schema, value); + this._validateNot(schema, value); + } + + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateConditional(schema, value) { + const ifSchema = schema.if; + if (typeof ifSchema === 'undefined') { return; } let okay = true; this._schemaPush(ifSchema, 'if'); try { - this._validate(value); + this._validate(ifSchema, value); } catch (e) { okay = false; } finally { this._schemaPop(); } - const nextSchema = okay ? this._schema.then : this._schema.else; - if (this._isObject(nextSchema)) { return; } + const nextSchema = okay ? schema.then : schema.else; + if (typeof nextSchema === 'undefined') { return; } this._schemaPush(nextSchema, okay ? 'then' : 'else'); try { - this._validate(value); + this._validate(nextSchema, value); } finally { this._schemaPop(); } } - _validateAllOf(value) { - const subSchemas = this._schema.allOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAllOf(schema, value) { + const subSchemas = schema.allOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'allOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); } finally { this._schemaPop(); } @@ -471,19 +711,21 @@ export class JsonSchema { } } - _validateAnyOf(value) { - const subSchemas = this._schema.anyOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateAnyOf(schema, value) { + const subSchemas = schema.anyOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'anyOf'); try { for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); return; } catch (e) { // NOP @@ -498,8 +740,12 @@ export class JsonSchema { } } - _validateOneOf(value) { - const subSchemas = this._schema.oneOf; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + */ + _validateOneOf(schema, value) { + const subSchemas = schema.oneOf; if (!Array.isArray(subSchemas)) { return; } this._schemaPush(subSchemas, 'oneOf'); @@ -507,11 +753,9 @@ export class JsonSchema { let count = 0; for (let i = 0, ii = subSchemas.length; i < ii; ++i) { const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } - this._schemaPush(subSchema, i); try { - this._validate(value); + this._validate(subSchema, value); ++count; } catch (e) { // NOP @@ -528,33 +772,37 @@ export class JsonSchema { } } - _validateNoneOf(value) { - const subSchemas = this._schema.not; - if (!Array.isArray(subSchemas)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateNot(schema, value) { + const notSchema = schema.not; + if (typeof notSchema === 'undefined') { return; } - this._schemaPush(subSchemas, 'not'); - try { - for (let i = 0, ii = subSchemas.length; i < ii; ++i) { - const subSchema = subSchemas[i]; - if (!this._isObject(subSchema)) { continue; } + if (Array.isArray(notSchema)) { + throw this._createError('not schema is an array'); + } - this._schemaPush(subSchema, i); - try { - this._validate(value); - } catch (e) { - continue; - } finally { - this._schemaPop(); - } - throw this._createError(`not[${i}] schema matched`); - } + this._schemaPush(notSchema, 'not'); + try { + this._validate(notSchema, value); + } catch (e) { + return; } finally { this._schemaPop(); } + throw this._createError('not schema matched'); } - _validateSingleSchema(value) { - const {type: schemaType, const: schemaConst, enum: schemaEnum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown} value + * @throws {Error} + */ + _validateSingleSchema(schema, value) { + const {type: schemaType, const: schemaConst, enum: schemaEnum} = schema; const type = this._getValueType(value); if (!this._isValueTypeAny(value, type, schemaType)) { throw this._createError(`Value type ${type} does not match schema type ${schemaType}`); @@ -570,22 +818,27 @@ export class JsonSchema { switch (type) { case 'number': - this._validateNumber(value); + this._validateNumber(schema, /** @type {number} */ (value)); break; case 'string': - this._validateString(value); + this._validateString(schema, /** @type {string} */ (value)); break; case 'array': - this._validateArray(value); + this._validateArray(schema, /** @type {import('json-schema').Value[]} */ (value)); break; case 'object': - this._validateObject(value); + this._validateObject(schema, /** @type {import('json-schema').ValueObject} */ (value)); break; } } - _validateNumber(value) { - const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {number} value + * @throws {Error} + */ + _validateNumber(schema, value) { + const {multipleOf, minimum, exclusiveMinimum, maximum, exclusiveMaximum} = schema; if (typeof multipleOf === 'number' && Math.floor(value / multipleOf) * multipleOf !== value) { throw this._createError(`Number is not a multiple of ${multipleOf}`); } @@ -607,8 +860,13 @@ export class JsonSchema { } } - _validateString(value) { - const {minLength, maxLength, pattern} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {string} value + * @throws {Error} + */ + _validateString(schema, value) { + const {minLength, maxLength, pattern} = schema; if (typeof minLength === 'number' && value.length < minLength) { throw this._createError('String length too short'); } @@ -618,14 +876,14 @@ export class JsonSchema { } if (typeof pattern === 'string') { - let {patternFlags} = this._schema; + let {patternFlags} = schema; if (typeof patternFlags !== 'string') { patternFlags = ''; } let regex; try { regex = this._getRegex(pattern, patternFlags); } catch (e) { - throw this._createError(`Pattern is invalid (${e.message})`); + throw this._createError(`Pattern is invalid (${e instanceof Error ? e.message : `${e}`})`); } if (!regex.test(value)) { @@ -634,8 +892,13 @@ export class JsonSchema { } } - _validateArray(value) { - const {minItems, maxItems} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArray(schema, value) { + const {minItems, maxItems} = schema; const {length} = value; if (typeof minItems === 'number' && length < minItems) { @@ -646,30 +909,35 @@ export class JsonSchema { throw this._createError('Array length too long'); } - this._validateArrayContains(value); + this._validateArrayContains(schema, value); for (let i = 0; i < length; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { throw this._createError(`No schema found for array[${i}]`); } const propertyValue = value[i]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(itemSchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } - _validateArrayContains(value) { - const containsSchema = this._schema.contains; - if (!this._isObject(containsSchema)) { return; } + /** + * @param {import('json-schema').SchemaObject} schema + * @param {unknown[]} value + * @throws {Error} + */ + _validateArrayContains(schema, value) { + const containsSchema = schema.contains; + if (typeof containsSchema === 'undefined') { return; } this._schemaPush(containsSchema, 'contains'); try { @@ -677,7 +945,7 @@ export class JsonSchema { const propertyValue = value[i]; this._valuePush(propertyValue, i); try { - this._validate(propertyValue); + this._validate(containsSchema, propertyValue); return; } catch (e) { // NOP @@ -691,8 +959,13 @@ export class JsonSchema { } } - _validateObject(value) { - const {required, minProperties, maxProperties} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @throws {Error} + */ + _validateObject(schema, value) { + const {required, minProperties, maxProperties} = schema; const properties = Object.getOwnPropertyNames(value); const {length} = properties; @@ -714,27 +987,32 @@ export class JsonSchema { for (let i = 0; i < length; ++i) { const property = properties[i]; - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { throw this._createError(`No schema found for ${property}`); } const propertyValue = value[property]; - this._schemaPush(schemaInfo.schema, schemaInfo.path); + this._schemaPushMultiple(stack); this._valuePush(propertyValue, property); try { - this._validate(propertyValue); + this._validate(propertySchema, propertyValue); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } } // Creation + /** + * @param {import('json-schema').Type|import('json-schema').Type[]|undefined} type + * @returns {import('json-schema').Value} + */ _getDefaultTypeValue(type) { + if (Array.isArray(type)) { type = type[0]; } if (typeof type === 'string') { switch (type) { case 'null': @@ -755,95 +1033,122 @@ export class JsonSchema { return null; } - _getDefaultSchemaValue() { - const {type: schemaType, default: schemaDefault} = this._schema; + /** + * @param {import('json-schema').SchemaObject} schema + * @returns {import('json-schema').Value} + */ + _getDefaultSchemaValue(schema) { + const {type: schemaType, default: schemaDefault} = schema; return ( typeof schemaDefault !== 'undefined' && this._isValueTypeAny(schemaDefault, this._getValueType(schemaDefault), schemaType) ? - clone(schemaDefault) : + JsonSchema.clone(schemaDefault) : this._getDefaultTypeValue(schemaType) ); } - _getValidValueOrDefault(path, value, schemaInfo) { - schemaInfo = this._getResolveSchemaInfo(schemaInfo); - this._schemaPush(schemaInfo.schema, schemaInfo.path); + /** + * @param {import('json-schema').Schema} schema + * @param {string|number|null} path + * @param {unknown} value + * @param {import('json-schema').SchemaStackItem[]} stack + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefault(schema, path, value, stack) { + ({schema, stack} = this._getResolvedSchemaInfo(schema, stack)); + this._schemaPushMultiple(stack); this._valuePush(value, path); try { - return this._getValidValueOrDefaultInner(value); + return this._getValidValueOrDefaultInner(schema, value); } finally { this._valuePop(); - this._schemaPop(); + this._schemaPopMultiple(stack.length); } } - _getValidValueOrDefaultInner(value) { + /** + * @param {import('json-schema').Schema} schema + * @param {unknown} value + * @returns {import('json-schema').Value} + */ + _getValidValueOrDefaultInner(schema, value) { let type = this._getValueType(value); - if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, this._schema.type)) { - value = this._getDefaultSchemaValue(); + if (typeof schema === 'boolean') { + return type !== null ? /** @type {import('json-schema').ValueObject} */ (value) : null; + } + if (typeof value === 'undefined' || !this._isValueTypeAny(value, type, schema.type)) { + value = this._getDefaultSchemaValue(schema); type = this._getValueType(value); } switch (type) { case 'object': - value = this._populateObjectDefaults(value); - break; + return this._populateObjectDefaults(schema, /** @type {import('json-schema').ValueObject} */ (value)); case 'array': - value = this._populateArrayDefaults(value); - break; + return this._populateArrayDefaults(schema, /** @type {import('json-schema').Value[]} */ (value)); default: - if (!this._isValidCurrent(value)) { - const schemaDefault = this._getDefaultSchemaValue(); - if (this._isValidCurrent(schemaDefault)) { - value = schemaDefault; + if (!this._isValidCurrent(schema, value)) { + const schemaDefault = this._getDefaultSchemaValue(schema); + if (this._isValidCurrent(schema, schemaDefault)) { + return schemaDefault; } } break; } - return value; + return /** @type {import('json-schema').ValueObject} */ (value); } - _populateObjectDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').ValueObject} value + * @returns {import('json-schema').ValueObject} + */ + _populateObjectDefaults(schema, value) { const properties = new Set(Object.getOwnPropertyNames(value)); - const {required} = this._schema; + const {required} = schema; if (Array.isArray(required)) { for (const property of required) { properties.delete(property); - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { continue; } + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { continue; } const propertyValue = Object.prototype.hasOwnProperty.call(value, property) ? value[property] : void 0; - value[property] = this._getValidValueOrDefault(property, propertyValue, schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, propertyValue, stack); } } for (const property of properties) { - const schemaInfo = this._getObjectPropertySchemaInfo(property); - if (schemaInfo === null) { + const {schema: propertySchema, stack} = this._getObjectPropertySchemaInfo(schema, property); + if (propertySchema === false) { Reflect.deleteProperty(value, property); } else { - value[property] = this._getValidValueOrDefault(property, value[property], schemaInfo); + value[property] = this._getValidValueOrDefault(propertySchema, property, value[property], stack); } } return value; } - _populateArrayDefaults(value) { + /** + * @param {import('json-schema').SchemaObject} schema + * @param {import('json-schema').Value[]} value + * @returns {import('json-schema').Value[]} + */ + _populateArrayDefaults(schema, value) { for (let i = 0, ii = value.length; i < ii; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { continue; } + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { continue; } const propertyValue = value[i]; - value[i] = this._getValidValueOrDefault(i, propertyValue, schemaInfo); + value[i] = this._getValidValueOrDefault(itemSchema, i, propertyValue, stack); } - const {minItems, maxItems} = this._schema; + const {minItems, maxItems} = schema; if (typeof minItems === 'number' && value.length < minItems) { for (let i = value.length; i < minItems; ++i) { - const schemaInfo = this._getArrayItemSchemaInfo(i); - if (schemaInfo === null) { break; } - const item = this._getValidValueOrDefault(i, void 0, schemaInfo); + const {schema: itemSchema, stack} = this._getArrayItemSchemaInfo(schema, i); + if (itemSchema === false) { break; } + const item = this._getValidValueOrDefault(itemSchema, i, void 0, stack); value.push(item); } } @@ -856,115 +1161,187 @@ export class JsonSchema { } } +/** + * @implements {ProxyHandler<import('json-schema').ValueObjectOrArray>} + */ class JsonSchemaProxyHandler { - constructor(schema) { - this._schema = schema; + /** + * @param {JsonSchema} schemaValidator + */ + constructor(schemaValidator) { + /** @type {JsonSchema} */ + this._schemaValidator = schemaValidator; + /** @type {RegExp} */ this._numberPattern = /^(?:0|[1-9]\d*)$/; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {?import('core').UnknownObject} + */ getPrototypeOf(target) { return Object.getPrototypeOf(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, newPrototype: ?unknown) => boolean} + */ setPrototypeOf() { throw new Error('setPrototypeOf not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ isExtensible(target) { return Object.isExtensible(target); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {boolean} + */ preventExtensions(target) { Object.preventExtensions(target); return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {PropertyDescriptor|undefined} + */ getOwnPropertyDescriptor(target, property) { return Object.getOwnPropertyDescriptor(target, property); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, property: string|symbol, attributes: PropertyDescriptor) => boolean} + */ defineProperty() { throw new Error('defineProperty not supported'); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + */ has(target, property) { return property in target; } - get(target, property) { - if (typeof property === 'symbol') { return target[property]; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @param {import('core').SafeAny} _receiver + * @returns {import('core').SafeAny} + */ + get(target, property, _receiver) { + if (typeof property === 'symbol') { return /** @type {import('core').UnknownObject} */ (target)[property]; } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { // Note: this does not currently wrap mutating functions like push, pop, shift, unshift, splice - return target[property]; + return /** @type {import('core').SafeAny} */ (target)[property]; } - property = index; - propertySchema = this._schema.getArrayItemSchema(property); + property = `${index}`; + propertySchema = this._schemaValidator.getArrayItemSchema(index); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { return void 0; } - const value = target[property]; - return value !== null && typeof value === 'object' ? propertySchema.createProxy(value) : value; + const value = /** @type {import('core').UnknownObject} */ (target)[property]; + return value !== null && typeof value === 'object' ? propertySchema.createProxy(/** @type {import('json-schema').Value} */ (value)) : value; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|number|symbol} property + * @param {import('core').SafeAny} value + * @returns {boolean} + * @throws {Error} + */ set(target, property, value) { - if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${property}`); } + if (typeof property === 'symbol') { throw new Error(`Cannot assign symbol property ${typeof property === 'symbol' ? '<symbol>' : property}`); } let propertySchema; if (Array.isArray(target)) { const index = this._getArrayIndex(property); if (index === null) { - target[property] = value; + /** @type {import('core').SafeAny} */ (target)[property] = value; return true; } if (index > target.length) { throw new Error('Array index out of range'); } property = index; - propertySchema = this._schema.getArrayItemSchema(property); + propertySchema = this._schemaValidator.getArrayItemSchema(property); } else { - propertySchema = this._schema.getObjectPropertySchema(property); + if (typeof property !== 'string') { + property = `${property}`; + } + propertySchema = this._schemaValidator.getObjectPropertySchema(property); } if (propertySchema === null) { throw new Error(`Property ${property} not supported`); } - value = clone(value); + value = JsonSchema.clone(value); propertySchema.validate(value); - target[property] = value; + /** @type {import('core').UnknownObject} */ (target)[property] = value; return true; } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @param {string|symbol} property + * @returns {boolean} + * @throws {Error} + */ deleteProperty(target, property) { const required = ( (typeof target === 'object' && target !== null) ? - (!Array.isArray(target) && this._schema.isObjectPropertyRequired(property)) : + (!Array.isArray(target) && typeof property === 'string' && this._schemaValidator.isObjectPropertyRequired(property)) : true ); if (required) { - throw new Error(`${property} cannot be deleted`); + throw new Error(`${typeof property === 'symbol' ? '<symbol>' : property} cannot be deleted`); } return Reflect.deleteProperty(target, property); } + /** + * @param {import('json-schema').ValueObjectOrArray} target + * @returns {ArrayLike<string|symbol>} + */ ownKeys(target) { return Reflect.ownKeys(target); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, thisArg: import('core').SafeAny, argArray: import('core').SafeAny[]) => import('core').SafeAny} + */ apply() { throw new Error('apply not supported'); } + /** + * @type {(target: import('json-schema').ValueObjectOrArray, argArray: import('core').SafeAny[], newTarget: import('core').SafeFunction) => import('json-schema').ValueObjectOrArray} + */ construct() { throw new Error('construct not supported'); } // Private + /** + * @param {string|symbol|number} property + * @returns {?number} + */ _getArrayIndex(property) { if (typeof property === 'string' && this._numberPattern.test(property)) { return Number.parseInt(property, 10); diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 3858cb55..70c1622f 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -22,20 +22,29 @@ import {JsonSchema} from './json-schema.js'; export class OptionsUtil { constructor() { + /** @type {?TemplatePatcher} */ this._templatePatcher = null; + /** @type {?JsonSchema} */ this._optionsSchema = null; } + /** */ async prepare() { - const schema = await this._fetchAsset('/data/schemas/options-schema.json', true); + const schema = /** @type {import('json-schema').Schema} */ (await this._fetchJson('/data/schemas/options-schema.json')); this._optionsSchema = new JsonSchema(schema); } - async update(options, targetVersion=null) { + /** + * @param {unknown} optionsInput + * @param {?number} [targetVersion] + * @returns {Promise<import('settings').Options>} + */ + async update(optionsInput, targetVersion=null) { // Invalid options - if (!isObject(options)) { - options = {}; - } + let options = /** @type {{[key: string]: unknown}} */ ( + typeof optionsInput === 'object' && optionsInput !== null && !Array.isArray(optionsInput) ? + optionsInput : {} + ); // Check for legacy options let defaultProfileOptions = {}; @@ -50,7 +59,7 @@ export class OptionsUtil { } // Remove invalid profiles - const profiles = options.profiles; + const profiles = /** @type {unknown[]} */ (options.profiles); for (let i = profiles.length - 1; i >= 0; --i) { if (!isObject(profiles[i])) { profiles.splice(i, 1); @@ -87,12 +96,12 @@ export class OptionsUtil { options = await this._applyUpdates(options, this._getVersionUpdates(targetVersion)); // Validation - options = this._optionsSchema.getValidValueOrDefault(options); - - // Result - return options; + return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault(options)); } + /** + * @returns {Promise<import('settings').Options>} + */ async load() { let options; try { @@ -121,6 +130,10 @@ export class OptionsUtil { return options; } + /** + * @param {import('settings').Options} options + * @returns {Promise<void>} + */ save(options) { return new Promise((resolve, reject) => { chrome.storage.local.set({options: JSON.stringify(options)}, () => { @@ -134,23 +147,36 @@ export class OptionsUtil { }); } + /** + * @returns {import('settings').Options} + */ getDefault() { - const optionsVersion = this._getVersionUpdates().length; - const options = this._optionsSchema.getValidValueOrDefault(); + const optionsVersion = this._getVersionUpdates(null).length; + const options = /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).getValidValueOrDefault()); options.version = optionsVersion; return options; } + /** + * @param {import('settings').Options} options + * @returns {import('settings').Options} + */ createValidatingProxy(options) { - return this._optionsSchema.createProxy(options); + return /** @type {import('settings').Options} */ (/** @type {JsonSchema} */ (this._optionsSchema).createProxy(options)); } + /** + * @param {import('settings').Options} options + */ validate(options) { - return this._optionsSchema.validate(options); + /** @type {JsonSchema} */ (this._optionsSchema).validate(options); } // Legacy profile updating + /** + * @returns {(?import('options-util').LegacyUpdateFunction)[]} + */ _legacyProfileUpdateGetUpdates() { return [ null, @@ -242,6 +268,9 @@ export class OptionsUtil { ]; } + /** + * @returns {import('options-util').LegacyOptions} + */ _legacyProfileUpdateGetDefaults() { return { general: { @@ -341,9 +370,17 @@ export class OptionsUtil { }; } + /** + * @param {import('options-util').IntermediateOptions} options + * @returns {import('options-util').IntermediateOptions} + */ _legacyProfileUpdateAssignDefaults(options) { const defaults = this._legacyProfileUpdateGetDefaults(); + /** + * @param {import('options-util').IntermediateOptions} target + * @param {import('core').UnknownObject} source + */ const combine = (target, source) => { for (const key in source) { if (!Object.prototype.hasOwnProperty.call(target, key)) { @@ -362,6 +399,10 @@ export class OptionsUtil { return options; } + /** + * @param {import('options-util').IntermediateOptions} options + * @returns {import('options-util').IntermediateOptions} + */ _legacyProfileUpdateUpdateVersion(options) { const updates = this._legacyProfileUpdateGetUpdates(); this._legacyProfileUpdateAssignDefaults(options); @@ -384,6 +425,10 @@ export class OptionsUtil { // Private + /** + * @param {import('options-util').IntermediateOptions} options + * @param {string} modificationsUrl + */ async _applyAnkiFieldTemplatesPatch(options, modificationsUrl) { let patch = null; for (const {options: profileOptions} of options.profiles) { @@ -391,18 +436,22 @@ export class OptionsUtil { if (fieldTemplates === null) { continue; } if (patch === null) { - const content = await this._fetchAsset(modificationsUrl); + const content = await this._fetchText(modificationsUrl); if (this._templatePatcher === null) { this._templatePatcher = new TemplatePatcher(); } patch = this._templatePatcher.parsePatch(content); } - profileOptions.anki.fieldTemplates = this._templatePatcher.applyPatch(fieldTemplates, patch); + profileOptions.anki.fieldTemplates = /** @type {TemplatePatcher} */ (this._templatePatcher).applyPatch(fieldTemplates, patch); } } - async _fetchAsset(url, json=false) { + /** + * @param {string} url + * @returns {Promise<Response>} + */ + async _fetchGeneric(url) { url = chrome.runtime.getURL(url); const response = await fetch(url, { method: 'GET', @@ -415,9 +464,31 @@ export class OptionsUtil { if (!response.ok) { throw new Error(`Failed to fetch ${url}: ${response.status}`); } - return await (json ? response.json() : response.text()); + return response; + } + + /** + * @param {string} url + * @returns {Promise<string>} + */ + async _fetchText(url) { + const response = await this._fetchGeneric(url); + return await response.text(); + } + + /** + * @param {string} url + * @returns {Promise<unknown>} + */ + async _fetchJson(url) { + const response = await this._fetchGeneric(url); + return await response.json(); } + /** + * @param {string} string + * @returns {number} + */ _getStringHashCode(string) { let hashCode = 0; @@ -431,6 +502,11 @@ export class OptionsUtil { return hashCode; } + /** + * @param {import('options-util').IntermediateOptions} options + * @param {import('options-util').ModernUpdate[]} updates + * @returns {Promise<import('settings').Options>} + */ async _applyUpdates(options, updates) { const targetVersion = updates.length; let currentVersion = options.version; @@ -449,6 +525,10 @@ export class OptionsUtil { return options; } + /** + * @param {?number} targetVersion + * @returns {import('options-util').ModernUpdate[]} + */ _getVersionUpdates(targetVersion) { const result = [ {async: false, update: this._updateVersion1.bind(this)}, @@ -479,6 +559,9 @@ export class OptionsUtil { return result; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion1(options) { // Version 1 changes: // Added options.global.database.prefixWildcardsSupported = false. @@ -490,6 +573,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion2(options) { // Version 2 changes: // Legacy profile update process moved into this upgrade function. @@ -502,6 +588,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion3(options) { // Version 3 changes: // Pitch accent Anki field templates added. @@ -509,6 +598,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion4(options) { // Version 4 changes: // Options conditions converted to string representations. @@ -594,6 +686,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion5(options) { // Version 5 changes: // Removed legacy version number from profile options. @@ -603,6 +698,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion6(options) { // Version 6 changes: // Updated handlebars templates to include "conjugation" definition. @@ -625,6 +723,10 @@ export class OptionsUtil { return options; } + /** + * @param {string} templates + * @returns {string} + */ _updateVersion6AnkiTemplatesCompactTags(templates) { const rawPattern1 = '{{~#if definitionTags~}}<i>({{#each definitionTags}}{{name}}{{#unless @last}}, {{/unless}}{{/each}})</i> {{/if~}}'; const pattern1 = new RegExp(`((\r?\n)?[ \t]*)${escapeRegExp(rawPattern1)}`, 'g'); @@ -649,6 +751,9 @@ export class OptionsUtil { return templates; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion7(options) { // Version 7 changes: // Added general.maximumClipboardSearchLength. @@ -666,6 +771,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion8(options) { // Version 8 changes: // Added translation.textReplacements. @@ -755,6 +863,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion9(options) { // Version 9 changes: // Added general.frequencyDisplayMode. @@ -766,6 +877,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion10(options) { // Version 10 changes: // Removed global option useSettingsV2. @@ -803,6 +917,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion11(options) { // Version 11 changes: // Changed dictionaries to an array. @@ -827,6 +944,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion12(options) { // Version 12 changes: // Changed sentenceParsing.enableTerminationCharacters to sentenceParsing.terminationCharacterMode. @@ -841,7 +961,7 @@ export class OptionsUtil { delete sentenceParsing.enableTerminationCharacters; const {sources, customSourceUrl, customSourceType, textToSpeechVoice} = audio; - audio.sources = sources.map((type) => { + audio.sources = /** @type {string[]} */ (sources).map((type) => { switch (type) { case 'text-to-speech': case 'text-to-speech-reading': @@ -859,6 +979,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion13(options) { // Version 13 changes: // Handlebars templates updated to use formatGlossary. @@ -874,6 +997,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion14(options) { // Version 14 changes: // Added accessibility options. @@ -885,6 +1011,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion15(options) { // Version 15 changes: // Added general.sortFrequencyDictionary. @@ -896,6 +1025,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion16(options) { // Version 16 changes: // Added scanning.matchTypePrefix. @@ -905,12 +1037,16 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion17(options) { // Version 17 changes: // Added vertical sentence punctuation to terminationCharacters. const additions = ['︒', '︕', '︖', '︙']; for (const profile of options.profiles) { - const {terminationCharacters} = profile.options.sentenceParsing; + /** @type {import('settings').SentenceParsingTerminationCharacterOption[]} */ + const terminationCharacters = profile.options.sentenceParsing.terminationCharacters; const newAdditions = []; for (const character of additions) { if (terminationCharacters.findIndex((value) => (value.character1 === character && value.character2 === null)) < 0) { @@ -930,6 +1066,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion18(options) { // Version 18 changes: // general.popupTheme's 'default' value changed to 'light' @@ -952,6 +1091,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion19(options) { // Version 19 changes: // Added anki.noteGuiMode. @@ -979,6 +1121,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionSync} + */ _updateVersion20(options) { // Version 20 changes: // Added anki.downloadTimeout. @@ -999,6 +1144,9 @@ export class OptionsUtil { return options; } + /** + * @type {import('options-util').ModernUpdateFunctionAsync} + */ async _updateVersion21(options) { await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v21.handlebars'); @@ -1018,6 +1166,10 @@ export class OptionsUtil { return options; } + /** + * @param {string} url + * @returns {Promise<chrome.tabs.Tab>} + */ _createTab(url) { return new Promise((resolve, reject) => { chrome.tabs.create({url}, (tab) => { diff --git a/ext/js/data/permissions-util.js b/ext/js/data/permissions-util.js index d645f21e..76c5031b 100644 --- a/ext/js/data/permissions-util.js +++ b/ext/js/data/permissions-util.js @@ -20,12 +20,17 @@ import {AnkiUtil} from './anki-util.js'; export class PermissionsUtil { constructor() { + /** @type {Set<string>} */ this._ankiFieldMarkersRequiringClipboardPermission = new Set([ 'clipboard-image', 'clipboard-text' ]); } + /** + * @param {chrome.permissions.Permissions} permissions + * @returns {Promise<boolean>} + */ hasPermissions(permissions) { return new Promise((resolve, reject) => chrome.permissions.contains(permissions, (result) => { const e = chrome.runtime.lastError; @@ -37,6 +42,11 @@ export class PermissionsUtil { })); } + /** + * @param {chrome.permissions.Permissions} permissions + * @param {boolean} shouldHave + * @returns {Promise<boolean>} + */ setPermissionsGranted(permissions, shouldHave) { return ( shouldHave ? @@ -59,6 +69,9 @@ export class PermissionsUtil { ); } + /** + * @returns {Promise<chrome.permissions.Permissions>} + */ getAllPermissions() { return new Promise((resolve, reject) => chrome.permissions.getAll((result) => { const e = chrome.runtime.lastError; @@ -70,6 +83,10 @@ export class PermissionsUtil { })); } + /** + * @param {string} fieldValue + * @returns {string[]} + */ getRequiredPermissionsForAnkiFieldValue(fieldValue) { const markers = AnkiUtil.getFieldMarkers(fieldValue); const markerPermissions = this._ankiFieldMarkersRequiringClipboardPermission; @@ -81,6 +98,11 @@ export class PermissionsUtil { return []; } + /** + * @param {chrome.permissions.Permissions} permissions + * @param {import('settings').ProfileOptions} options + * @returns {boolean} + */ hasRequiredPermissionsForOptions(permissions, options) { const permissionsSet = new Set(permissions.permissions); diff --git a/ext/js/data/sandbox/anki-note-data-creator.js b/ext/js/data/sandbox/anki-note-data-creator.js index 371a62a2..dce71938 100644 --- a/ext/js/data/sandbox/anki-note-data-creator.js +++ b/ext/js/data/sandbox/anki-note-data-creator.js @@ -25,24 +25,18 @@ import {DictionaryDataUtil} from '../../language/sandbox/dictionary-data-util.js export class AnkiNoteDataCreator { /** * Creates a new instance. - * @param {JapaneseUtil} japaneseUtil An instance of `JapaneseUtil`. + * @param {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil An instance of `JapaneseUtil`. */ constructor(japaneseUtil) { + /** @type {import('../../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; } /** * Creates a compatibility representation of the specified data. * @param {string} marker The marker that is being used for template rendering. - * @param {object} details Information which is used to generate the data. - * @param {Translation.DictionaryEntry} details.dictionaryEntry The dictionary entry. - * @param {string} details.resultOutputMode The result output mode. - * @param {string} details.mode The mode being used to generate the Anki data. - * @param {string} details.glossaryLayoutMode The glossary layout mode. - * @param {boolean} details.compactTags Whether or not compact tags mode is enabled. - * @param {{documentTitle: string, query: string, fullQuery: string}} details.context Contextual information about the source of the dictionary entry. - * @param {object} details.media Media data. - * @returns {object} An object used for rendering Anki templates. + * @param {import('anki-templates-internal').CreateDetails} details Information which is used to generate the data. + * @returns {import('anki-templates').NoteData} An object used for rendering Anki templates. */ create(marker, { dictionaryEntry, @@ -53,6 +47,7 @@ export class AnkiNoteDataCreator { context, media }) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const definition = this.createCachedValue(this._getDefinition.bind(this, dictionaryEntry, context, resultOutputMode)); const uniqueExpressions = this.createCachedValue(this._getUniqueExpressions.bind(this, dictionaryEntry)); @@ -60,7 +55,18 @@ export class AnkiNoteDataCreator { const context2 = this.createCachedValue(this._getPublicContext.bind(this, context)); const pitches = this.createCachedValue(this._getPitches.bind(this, dictionaryEntry)); const pitchCount = this.createCachedValue(this._getPitchCount.bind(this, pitches)); - if (typeof media !== 'object' || media === null || Array.isArray(media)) { media = {}; } + if (typeof media !== 'object' || media === null || Array.isArray(media)) { + media = { + audio: void 0, + screenshot: void 0, + clipboardImage: void 0, + clipboardText: void 0, + selectionText: void 0, + textFurigana: [], + dictionaryMedia: {} + }; + } + /** @type {import('anki-templates').NoteData} */ const result = { marker, get definition() { return self.getCachedValue(definition); }, @@ -77,7 +83,8 @@ export class AnkiNoteDataCreator { get pitches() { return self.getCachedValue(pitches); }, get pitchCount() { return self.getCachedValue(pitchCount); }, get context() { return self.getCachedValue(context2); }, - media + media, + dictionaryEntry }; Object.defineProperty(result, 'dictionaryEntry', { configurable: false, @@ -90,8 +97,9 @@ export class AnkiNoteDataCreator { /** * Creates a deferred-evaluation value. - * @param {Function} getter The function to invoke to get the return value. - * @returns {{getter: Function, hasValue: false, value: undefined}} An object which can be passed into `getCachedValue`. + * @template [T=unknown] + * @param {() => T} getter The function to invoke to get the return value. + * @returns {import('anki-templates-internal').CachedValue<T>} An object which can be passed into `getCachedValue`. */ createCachedValue(getter) { return {getter, hasValue: false, value: void 0}; @@ -99,11 +107,12 @@ export class AnkiNoteDataCreator { /** * Gets the value of a cached object. - * @param {{getter: Function, hasValue: boolean, value: *}} item An object that was returned from `createCachedValue`. - * @returns {*} The result of evaluating the getter, which is cached after the first invocation. + * @template [T=unknown] + * @param {import('anki-templates-internal').CachedValue<T>} item An object that was returned from `createCachedValue`. + * @returns {T} The result of evaluating the getter, which is cached after the first invocation. */ getCachedValue(item) { - if (item.hasValue) { return item.value; } + if (item.hasValue) { return /** @type {T} */ (item.value); } const value = item.getter(); item.value = value; item.hasValue = true; @@ -112,10 +121,10 @@ export class AnkiNoteDataCreator { // Private - _asObject(value) { - return (typeof value === 'object' && value !== null ? value : {}); - } - + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {?import('dictionary').TermSource} + */ _getPrimarySource(dictionaryEntry) { for (const headword of dictionaryEntry.headwords) { for (const source of headword.sources) { @@ -125,6 +134,10 @@ export class AnkiNoteDataCreator { return null; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getUniqueExpressions(dictionaryEntry) { if (dictionaryEntry.type === 'term') { const results = new Set(); @@ -137,6 +150,10 @@ export class AnkiNoteDataCreator { } } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getUniqueReadings(dictionaryEntry) { if (dictionaryEntry.type === 'term') { const results = new Set(); @@ -149,8 +166,12 @@ export class AnkiNoteDataCreator { } } + /** + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Context} + */ _getPublicContext(context) { - let {documentTitle, query, fullQuery} = this._asObject(context); + let {documentTitle, query, fullQuery} = context; if (typeof documentTitle !== 'string') { documentTitle = ''; } return { query, @@ -161,10 +182,16 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').PitchGroup[]} + */ _getPitches(dictionaryEntry) { + /** @type {import('anki-templates').PitchGroup[]} */ const results = []; if (dictionaryEntry.type === 'term') { for (const {dictionary, pronunciations} of DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry)) { + /** @type {import('anki-templates').Pitch[]} */ const pitches = []; for (const {terms, reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} of pronunciations) { pitches.push({ @@ -173,7 +200,7 @@ export class AnkiNoteDataCreator { position, nasalPositions, devoicePositions, - tags, + tags: this._convertPitchTags(tags), exclusiveExpressions: exclusiveTerms, exclusiveReadings }); @@ -184,11 +211,21 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('anki-templates-internal').CachedValue<import('anki-templates').PitchGroup[]>} cachedPitches + * @returns {number} + */ _getPitchCount(cachedPitches) { const pitches = this.getCachedValue(cachedPitches); return pitches.reduce((i, v) => i + v.pitches.length, 0); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').DictionaryEntry} + */ _getDefinition(dictionaryEntry, context, resultOutputMode) { switch (dictionaryEntry.type) { case 'term': @@ -196,16 +233,22 @@ export class AnkiNoteDataCreator { case 'kanji': return this._getKanjiDefinition(dictionaryEntry, context); default: - return {}; + return /** @type {import('anki-templates').UnknownDictionaryEntry} */ ({}); } } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').KanjiDictionaryEntry} + */ _getKanjiDefinition(dictionaryEntry, context) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const {character, dictionary, onyomi, kunyomi, definitions} = dictionaryEntry; - let {url} = this._asObject(context); + let {url} = context; if (typeof url !== 'string') { url = ''; } const stats = this.createCachedValue(this._getKanjiStats.bind(this, dictionaryEntry)); @@ -228,14 +271,24 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiStatGroups} + */ _getKanjiStats(dictionaryEntry) { + /** @type {import('anki-templates').KanjiStatGroups} */ const results = {}; + const convertKanjiStatBind = this._convertKanjiStat.bind(this); for (const [key, value] of Object.entries(dictionaryEntry.stats)) { - results[key] = value.map(this._convertKanjiStat.bind(this)); + results[key] = value.map(convertKanjiStatBind); } return results; } + /** + * @param {import('dictionary').KanjiStat} kanjiStat + * @returns {import('anki-templates').KanjiStat} + */ _convertKanjiStat({name, category, content, order, score, dictionary, value}) { return { name, @@ -248,7 +301,12 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').KanjiFrequency[]} + */ _getKanjiFrequencies(dictionaryEntry) { + /** @type {import('anki-templates').KanjiFrequency[]} */ const results = []; for (const {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue} of dictionaryEntry.frequencies) { results.push({ @@ -265,9 +323,17 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @param {import('settings').ResultOutputMode} resultOutputMode + * @returns {import('anki-templates').TermDictionaryEntry} + */ _getTermDefinition(dictionaryEntry, context, resultOutputMode) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; + /** @type {import('anki-templates').TermDictionaryEntryType} */ let type = 'term'; switch (resultOutputMode) { case 'group': type = 'termGrouped'; break; @@ -276,7 +342,7 @@ export class AnkiNoteDataCreator { const {inflections, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, definitions} = dictionaryEntry; - let {url} = this._asObject(context); + let {url} = context; if (typeof url !== 'string') { url = ''; } const primarySource = this._getPrimarySource(dictionaryEntry); @@ -330,6 +396,10 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {string[]} + */ _getTermDictionaryNames(dictionaryEntry) { const dictionaryNames = new Set(); for (const {dictionary} of dictionaryEntry.definitions) { @@ -338,11 +408,18 @@ export class AnkiNoteDataCreator { return [...dictionaryNames]; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').TermDictionaryEntryCommonInfo} + */ _getTermDictionaryEntryCommonInfo(dictionaryEntry, type) { const merged = (type === 'termMerged'); const hasDefinitions = (type !== 'term'); + /** @type {Set<string>} */ const allTermsSet = new Set(); + /** @type {Set<string>} */ const allReadingsSet = new Set(); for (const {term, reading} of dictionaryEntry.headwords) { allTermsSet.add(term); @@ -351,7 +428,9 @@ export class AnkiNoteDataCreator { const uniqueTerms = [...allTermsSet]; const uniqueReadings = [...allReadingsSet]; + /** @type {import('anki-templates').TermDefinition[]} */ const definitions = []; + /** @type {import('anki-templates').Tag[]} */ const definitionTags = []; for (const {tags, headwordIndices, entries, dictionary, sequences} of dictionaryEntry.definitions) { const definitionTags2 = []; @@ -378,6 +457,10 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermFrequency[]} + */ _getTermFrequencies(dictionaryEntry) { const results = []; const {headwords} = dictionaryEntry; @@ -400,7 +483,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermPronunciation[]} + */ _getTermPitches(dictionaryEntry) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords} = dictionaryEntry; @@ -423,7 +511,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermPitch[]} pitches + * @returns {import('anki-templates').TermPitch[]} + */ _getTermPitchesInner(pitches) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; for (const {position, tags} of pitches) { @@ -436,7 +529,12 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('anki-templates').TermHeadword[]} + */ _getTermExpressions(dictionaryEntry) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords} = dictionaryEntry; @@ -463,6 +561,11 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermFrequency[]} + */ _getTermExpressionFrequencies(dictionaryEntry, i) { const results = []; const {headwords, frequencies} = dictionaryEntry; @@ -486,7 +589,13 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {number} i + * @returns {import('anki-templates').TermPronunciation[]} + */ _getTermExpressionPitches(dictionaryEntry, i) { + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const results = []; const {headwords, pronunciations} = dictionaryEntry; @@ -510,11 +619,20 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('anki-templates-internal').CachedValue<import('anki-templates').Tag[]>} cachedTermTags + * @returns {import('anki-templates').TermFrequencyType} + */ _getTermExpressionTermFrequency(cachedTermTags) { const termTags = this.getCachedValue(cachedTermTags); return DictionaryDataUtil.getTermFrequency(termTags); } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('dictionary-data').TermGlossary[]|undefined} + */ _getTermGlossaryArray(dictionaryEntry, type) { if (type === 'term') { const results = []; @@ -526,6 +644,11 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').Tag[]|undefined} + */ _getTermTags(dictionaryEntry, type) { if (type !== 'termMerged') { const results = []; @@ -537,6 +660,10 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').Tag[]} + */ _convertTags(tags) { const results = []; for (const tag of tags) { @@ -545,6 +672,10 @@ export class AnkiNoteDataCreator { return results; } + /** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').Tag} + */ _convertTag({name, category, content, order, score, dictionaries, redundant}) { return { name, @@ -557,6 +688,39 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').Tag[]} tags + * @returns {import('anki-templates').PitchTag[]} + */ + _convertPitchTags(tags) { + const results = []; + for (const tag of tags) { + results.push(this._convertPitchTag(tag)); + } + return results; + } + + /** + * @param {import('dictionary').Tag} tag + * @returns {import('anki-templates').PitchTag} + */ + _convertPitchTag({name, category, content, order, score, dictionaries, redundant}) { + return { + name, + category, + order, + score, + content: [...content], + dictionaries: [...dictionaries], + redundant + }; + } + + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('anki-templates-internal').Context} context + * @returns {import('anki-templates').Cloze} + */ _getCloze(dictionaryEntry, context) { let originalText = ''; switch (dictionaryEntry.type) { @@ -571,8 +735,12 @@ export class AnkiNoteDataCreator { break; } - const {sentence} = this._asObject(context); - let {text, offset} = this._asObject(sentence); + const {sentence} = context; + let text; + let offset; + if (typeof sentence === 'object' && sentence !== null) { + ({text, offset} = sentence); + } if (typeof text !== 'string') { text = ''; } if (typeof offset !== 'number') { offset = 0; } @@ -584,6 +752,11 @@ export class AnkiNoteDataCreator { }; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @param {import('anki-templates').TermDictionaryEntryType} type + * @returns {import('anki-templates').FuriganaSegment[]|undefined} + */ _getTermFuriganaSegments(dictionaryEntry, type) { if (type === 'term') { for (const {term, reading} of dictionaryEntry.headwords) { @@ -593,7 +766,13 @@ export class AnkiNoteDataCreator { return void 0; } + /** + * @param {string} term + * @param {string} reading + * @returns {import('anki-templates').FuriganaSegment[]} + */ _getTermHeadwordFuriganaSegments(term, reading) { + /** @type {import('anki-templates').FuriganaSegment[]} */ const result = []; for (const {text, reading: reading2} of this._japaneseUtil.distributeFurigana(term, reading)) { result.push({text, furigana: reading2}); @@ -601,11 +780,15 @@ export class AnkiNoteDataCreator { return result; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {number} + */ _getTermDictionaryEntrySequence(dictionaryEntry) { let hasSequence = false; let mainSequence = -1; - for (const {sequences, isPrimary} of dictionaryEntry.definitions) { - if (!isPrimary) { continue; } + if (!dictionaryEntry.isPrimary) { return mainSequence; } + for (const {sequences} of dictionaryEntry.definitions) { const sequence = sequences[0]; if (!hasSequence) { mainSequence = sequence; diff --git a/ext/js/debug/timer.js b/ext/js/debug/timer.js index acf3621b..7381dab5 100644 --- a/ext/js/debug/timer.js +++ b/ext/js/debug/timer.js @@ -17,8 +17,13 @@ */ export class Timer { + /** + * @param {string} name + */ constructor(name) { + /** @type {{name: string, time: number, children: Timer[]}[]} */ this.samples = []; + /** @type {?Timer} */ this.parent = null; this.sample(name); @@ -30,6 +35,9 @@ export class Timer { Timer.current = this; } + /** + * @param {string} name + */ sample(name) { const time = performance.now(); this.samples.push({ @@ -39,6 +47,9 @@ export class Timer { }); } + /** + * @param {boolean} skip + */ complete(skip) { this.sample('complete'); @@ -55,13 +66,20 @@ export class Timer { } } + /** + * @param {number} sampleIndex + * @returns {number} + */ duration(sampleIndex) { const sampleIndexIsValid = (typeof sampleIndex === 'number'); const startIndex = (sampleIndexIsValid ? sampleIndex : 0); - const endIndex = (sampleIndexIsValid ? sampleIndex + 1 : this.times.length - 1); - return (this.times[endIndex].time - this.times[startIndex].time); + const endIndex = (sampleIndexIsValid ? sampleIndex + 1 : this.samples.length - 1); + return (this.samples[endIndex].time - this.samples[startIndex].time); } + /** + * @returns {string} + */ toString() { const indent = ' '; const name = this.samples[0].name; @@ -70,6 +88,9 @@ export class Timer { return `${name} took ${duration.toFixed(8)}ms [${extensionName}]` + this._indentString(this.getSampleString(), indent); } + /** + * @returns {string} + */ getSampleString() { const indent = ' '; const duration = this.samples[this.samples.length - 1].time - this.samples[0].time; @@ -87,9 +108,15 @@ export class Timer { return message; } + /** + * @param {string} message + * @param {string} indent + * @returns {string} + */ _indentString(message, indent) { return message.replace(/\n/g, `\n${indent}`); } } +/** @type {?Timer} */ Timer.current = null; diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js index 2f94e414..d0da568c 100644 --- a/ext/js/display/display-anki.js +++ b/ext/js/display/display-anki.js @@ -16,54 +16,95 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventListenerCollection, deferPromise, isObject} from '../core.js'; +import {EventListenerCollection, deferPromise} from '../core.js'; import {AnkiNoteBuilder} from '../data/anki-note-builder.js'; import {AnkiUtil} from '../data/anki-util.js'; import {PopupMenu} from '../dom/popup-menu.js'; import {yomitan} from '../yomitan.js'; export class DisplayAnki { + /** + * @param {import('./display.js').Display} display + * @param {import('./display-audio.js').DisplayAudio} displayAudio + * @param {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + */ constructor(display, displayAudio, japaneseUtil) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {import('./display-audio.js').DisplayAudio} */ this._displayAudio = displayAudio; + /** @type {?string} */ this._ankiFieldTemplates = null; + /** @type {?string} */ this._ankiFieldTemplatesDefault = null; + /** @type {AnkiNoteBuilder} */ this._ankiNoteBuilder = new AnkiNoteBuilder({japaneseUtil}); + /** @type {?import('./display-notification.js').DisplayNotification} */ this._errorNotification = null; + /** @type {?EventListenerCollection} */ this._errorNotificationEventListeners = null; + /** @type {?import('./display-notification.js').DisplayNotification} */ this._tagsNotification = null; - this._updateAdderButtonsPromise = Promise.resolve(); + /** @type {?Promise<void>} */ + this._updateAdderButtonsPromise = null; + /** @type {?import('core').TokenObject} */ this._updateDictionaryEntryDetailsToken = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?import('display-anki').DictionaryEntryDetails[]} */ this._dictionaryEntryDetails = null; + /** @type {?import('anki-templates-internal').Context} */ this._noteContext = null; + /** @type {boolean} */ this._checkForDuplicates = false; + /** @type {boolean} */ this._suspendNewCards = false; + /** @type {boolean} */ this._compactTags = false; + /** @type {import('settings').ResultOutputMode} */ this._resultOutputMode = 'split'; + /** @type {import('settings').GlossaryLayoutMode} */ this._glossaryLayoutMode = 'default'; + /** @type {import('settings').AnkiDisplayTags} */ this._displayTags = 'never'; + /** @type {import('settings').AnkiDuplicateScope} */ this._duplicateScope = 'collection'; + /** @type {boolean} */ this._duplicateScopeCheckAllModels = false; + /** @type {import('settings').AnkiScreenshotFormat} */ this._screenshotFormat = 'png'; + /** @type {number} */ this._screenshotQuality = 100; + /** @type {number} */ this._scanLength = 10; + /** @type {import('settings').AnkiNoteGuiMode} */ this._noteGuiMode = 'browse'; + /** @type {?number} */ this._audioDownloadIdleTimeout = null; + /** @type {string[]} */ this._noteTags = []; + /** @type {Map<import('display-anki').CreateMode, import('settings').AnkiNoteOptions>} */ this._modeOptions = new Map(); + /** @type {Map<import('dictionary').DictionaryEntryType, import('display-anki').CreateMode[]>} */ this._dictionaryEntryTypeModeMap = new Map([ ['kanji', ['kanji']], ['term', ['term-kanji', 'term-kana']] ]); - this._menuContainer = document.querySelector('#popup-menus'); + /** @type {HTMLElement} */ + this._menuContainer = /** @type {HTMLElement} */ (document.querySelector('#popup-menus')); + /** @type {(event: MouseEvent) => void} */ this._onShowTagsBind = this._onShowTags.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onNoteAddBind = this._onNoteAdd.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onViewNoteButtonClickBind = this._onViewNoteButtonClick.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onViewNoteButtonContextMenuBind = this._onViewNoteButtonContextMenu.bind(this); + /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */ this._onViewNoteButtonMenuCloseBind = this._onViewNoteButtonMenuClose.bind(this); } + /** */ prepare() { this._noteContext = this._getNoteContext(); this._display.hotkeyHandler.registerActions([ @@ -80,13 +121,16 @@ export class DisplayAnki { this._display.on('logDictionaryEntryData', this._onLogDictionaryEntryData.bind(this)); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {Promise<import('display-anki').LogData>} + */ async getLogData(dictionaryEntry) { - const result = {}; - // Anki note data let ankiNoteData; let ankiNoteDataException; try { + if (this._noteContext === null) { throw new Error('Note context not initialized'); } ankiNoteData = await this._ankiNoteBuilder.getRenderingData({ dictionaryEntry, mode: 'test', @@ -99,12 +143,9 @@ export class DisplayAnki { } catch (e) { ankiNoteDataException = e; } - result.ankiNoteData = ankiNoteData; - if (typeof ankiNoteDataException !== 'undefined') { - result.ankiNoteDataException = ankiNoteDataException; - } // Anki notes + /** @type {import('display-anki').AnkiNoteLogData[]} */ const ankiNotes = []; const modes = this._getModes(dictionaryEntry.type === 'term'); for (const mode of modes) { @@ -114,8 +155,9 @@ export class DisplayAnki { try { ({note: note, errors, requirements} = await this._createNote(dictionaryEntry, mode, [])); } catch (e) { - errors = [e]; + errors = [e instanceof Error ? e : new Error(`${e}`)]; } + /** @type {import('display-anki').AnkiNoteLogData} */ const entry = {mode, note}; if (Array.isArray(errors) && errors.length > 0) { entry.errors = errors; @@ -125,13 +167,19 @@ export class DisplayAnki { } ankiNotes.push(entry); } - result.ankiNotes = ankiNotes; - return result; + return { + ankiNoteData, + ankiNoteDataException: ankiNoteDataException instanceof Error ? ankiNoteDataException : new Error(`${ankiNoteDataException}`), + ankiNotes + }; } // Private + /** + * @param {import('display').OptionsUpdatedEvent} details + */ _onOptionsUpdated({options}) { const { general: {resultOutputMode, glossaryLayoutMode, compactTags}, @@ -173,16 +221,21 @@ export class DisplayAnki { this._updateAnkiFieldTemplates(options); } + /** */ _onContentClear() { this._updateDictionaryEntryDetailsToken = null; this._dictionaryEntryDetails = null; this._hideErrorNotification(false); } + /** */ _onContentUpdateStart() { this._noteContext = this._getNoteContext(); } + /** + * @param {import('display').ContentUpdateEntryEvent} details + */ _onContentUpdateEntry({element}) { const eventListeners = this._eventListeners; for (const node of element.querySelectorAll('.action-button[data-action=view-tags]')) { @@ -198,45 +251,77 @@ export class DisplayAnki { } } + /** */ _onContentUpdateComplete() { this._updateDictionaryEntryDetails(); } + /** + * @param {import('display').LogDictionaryEntryDataEvent} details + */ _onLogDictionaryEntryData({dictionaryEntry, promises}) { promises.push(this.getLogData(dictionaryEntry)); } + /** + * @param {MouseEvent} e + */ _onNoteAdd(e) { e.preventDefault(); - const node = e.currentTarget; - const index = this._display.getElementDictionaryEntryIndex(node); - this._addAnkiNote(index, node.dataset.mode); + const element = /** @type {HTMLElement} */ (e.currentTarget); + const mode = this._getValidCreateMode(element.dataset.mode); + if (mode === null) { return; } + const index = this._display.getElementDictionaryEntryIndex(element); + this._addAnkiNote(index, mode); } + /** + * @param {MouseEvent} e + */ _onShowTags(e) { e.preventDefault(); - const tags = e.currentTarget.title; + const element = /** @type {HTMLElement} */ (e.currentTarget); + const tags = element.title; this._showTagsNotification(tags); } + /** + * @param {number} index + * @param {import('display-anki').CreateMode} mode + * @returns {?HTMLButtonElement} + */ _adderButtonFind(index, mode) { const entry = this._getEntry(index); return entry !== null ? entry.querySelector(`.action-button[data-action=add-note][data-mode="${mode}"]`) : null; } + /** + * @param {number} index + * @returns {?HTMLButtonElement} + */ _tagsIndicatorFind(index) { const entry = this._getEntry(index); return entry !== null ? entry.querySelector('.action-button[data-action=view-tags]') : null; } + /** + * @param {number} index + * @returns {?HTMLElement} + */ _getEntry(index) { const entries = this._display.dictionaryEntryNodes; return index >= 0 && index < entries.length ? entries[index] : null; } + /** + * @returns {?import('anki-templates-internal').Context} + */ _getNoteContext() { const {state} = this._display.history; - let {documentTitle, url, sentence} = (isObject(state) ? state : {}); + let documentTitle, url, sentence; + if (typeof state === 'object' && state !== null) { + ({documentTitle, url, sentence} = state); + } if (typeof documentTitle !== 'string') { documentTitle = document.title; } @@ -254,8 +339,10 @@ export class DisplayAnki { }; } + /** */ async _updateDictionaryEntryDetails() { const {dictionaryEntries} = this._display; + /** @type {?import('core').TokenObject} */ const token = {}; this._updateDictionaryEntryDetailsToken = token; if (this._updateAdderButtonsPromise !== null) { @@ -263,13 +350,13 @@ export class DisplayAnki { } if (this._updateDictionaryEntryDetailsToken !== token) { return; } - const {promise, resolve} = deferPromise(); + const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); try { this._updateAdderButtonsPromise = promise; const dictionaryEntryDetails = await this._getDictionaryEntryDetails(dictionaryEntries); if (this._updateDictionaryEntryDetailsToken !== token) { return; } this._dictionaryEntryDetails = dictionaryEntryDetails; - this._updateAdderButtons(); + this._updateAdderButtons(dictionaryEntryDetails); } finally { resolve(); if (this._updateAdderButtonsPromise === promise) { @@ -278,9 +365,11 @@ export class DisplayAnki { } } - _updateAdderButtons() { + /** + * @param {import('display-anki').DictionaryEntryDetails[]} dictionaryEntryDetails + */ + _updateAdderButtons(dictionaryEntryDetails) { const displayTags = this._displayTags; - const dictionaryEntryDetails = this._dictionaryEntryDetails; for (let i = 0, ii = dictionaryEntryDetails.length; i < ii; ++i) { let allNoteIds = null; for (const {mode, canAdd, noteIds, noteInfos, ankiError} of dictionaryEntryDetails[i].modeMap.values()) { @@ -303,6 +392,10 @@ export class DisplayAnki { } } + /** + * @param {number} i + * @param {(?import('anki').NoteInfo)[]} noteInfos + */ _setupTagsIndicator(i, noteInfos) { const tagsIndicator = this._tagsIndicatorFind(i); if (tagsIndicator === null) { @@ -310,8 +403,9 @@ export class DisplayAnki { } const displayTags = new Set(); - for (const {tags} of noteInfos) { - for (const tag of tags) { + for (const item of noteInfos) { + if (item === null) { continue; } + for (const tag of item.tags) { displayTags.add(tag); } } @@ -328,6 +422,9 @@ export class DisplayAnki { } } + /** + * @param {string} message + */ _showTagsNotification(message) { if (this._tagsNotification === null) { this._tagsNotification = this._display.createNotification(true); @@ -337,11 +434,18 @@ export class DisplayAnki { this._tagsNotification.open(); } + /** + * @param {import('display-anki').CreateMode} mode + */ _tryAddAnkiNoteForSelectedEntry(mode) { const index = this._display.selectedIndex; this._addAnkiNote(index, mode); } + /** + * @param {number} dictionaryEntryIndex + * @param {import('display-anki').CreateMode} mode + */ async _addAnkiNote(dictionaryEntryIndex, mode) { const dictionaryEntries = this._display.dictionaryEntries; const dictionaryEntryDetails = this._dictionaryEntryDetails; @@ -364,6 +468,7 @@ export class DisplayAnki { this._hideErrorNotification(true); + /** @type {Error[]} */ const allErrors = []; const progressIndicatorVisible = this._display.progressIndicatorVisible; const overrideToken = progressIndicatorVisible.setOverride(true); @@ -381,7 +486,7 @@ export class DisplayAnki { addNoteOkay = true; } catch (e) { allErrors.length = 0; - allErrors.push(e); + allErrors.push(e instanceof Error ? e : new Error(`${e}`)); } if (addNoteOkay) { @@ -392,7 +497,7 @@ export class DisplayAnki { try { await yomitan.api.suspendAnkiCardsForNote(noteId); } catch (e) { - allErrors.push(e); + allErrors.push(e instanceof Error ? e : new Error(`${e}`)); } } button.disabled = true; @@ -400,7 +505,7 @@ export class DisplayAnki { } } } catch (e) { - allErrors.push(e); + allErrors.push(e instanceof Error ? e : new Error(`${e}`)); } finally { progressIndicatorVisible.clearOverride(overrideToken); } @@ -412,6 +517,11 @@ export class DisplayAnki { } } + /** + * @param {import('anki-note-builder').Requirement[]} requirements + * @param {import('anki-note-builder').Requirement[]} outputRequirements + * @returns {?DisplayAnkiError} + */ _getAddNoteRequirementsError(requirements, outputRequirements) { if (outputRequirements.length === 0) { return null; } @@ -429,12 +539,16 @@ export class DisplayAnki { } if (count === 0) { return null; } - const error = new Error('The created card may not have some content'); + const error = new DisplayAnkiError('The created card may not have some content'); error.requirements = requirements; error.outputRequirements = outputRequirements; return error; } + /** + * @param {Error[]} errors + * @param {(DocumentFragment|Node|Error)[]} [displayErrors] + */ _showErrorNotification(errors, displayErrors) { if (typeof displayErrors === 'undefined') { displayErrors = errors; } @@ -449,7 +563,7 @@ export class DisplayAnki { const content = this._display.displayGenerator.createAnkiNoteErrorsNotificationContent(displayErrors); for (const node of content.querySelectorAll('.anki-note-error-log-link')) { - this._errorNotificationEventListeners.addEventListener(node, 'click', () => { + /** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).addEventListener(node, 'click', () => { console.log({ankiNoteErrors: errors}); }, false); } @@ -458,16 +572,26 @@ export class DisplayAnki { this._errorNotification.open(); } + /** + * @param {boolean} animate + */ _hideErrorNotification(animate) { if (this._errorNotification === null) { return; } this._errorNotification.close(animate); - this._errorNotificationEventListeners.removeAllEventListeners(); + /** @type {EventListenerCollection} */ (this._errorNotificationEventListeners).removeAllEventListeners(); } + /** + * @param {import('settings').ProfileOptions} options + */ async _updateAnkiFieldTemplates(options) { this._ankiFieldTemplates = await this._getAnkiFieldTemplates(options); } + /** + * @param {import('settings').ProfileOptions} options + * @returns {Promise<string>} + */ async _getAnkiFieldTemplates(options) { let templates = options.anki.fieldTemplates; if (typeof templates === 'string') { return templates; } @@ -480,6 +604,10 @@ export class DisplayAnki { return templates; } + /** + * @param {import('dictionary').DictionaryEntry[]} dictionaryEntries + * @returns {Promise<import('display-anki').DictionaryEntryDetails[]>} + */ async _getDictionaryEntryDetails(dictionaryEntries) { const forceCanAddValue = (this._checkForDuplicates ? null : true); const fetchAdditionalInfo = (this._displayTags !== 'never'); @@ -514,9 +642,10 @@ export class DisplayAnki { } } catch (e) { infos = this._getAnkiNoteInfoForceValue(notes, false); - ankiError = e; + ankiError = e instanceof Error ? e : new Error(`${e}`); } + /** @type {import('display-anki').DictionaryEntryDetails[]} */ const results = []; for (let i = 0, ii = dictionaryEntries.length; i < ii; ++i) { results.push({ @@ -533,6 +662,11 @@ export class DisplayAnki { return results; } + /** + * @param {import('anki').Note[]} notes + * @param {boolean} canAdd + * @returns {import('anki').NoteInfoWrapper[]} + */ _getAnkiNoteInfoForceValue(notes, canAdd) { const results = []; for (const note of notes) { @@ -542,11 +676,19 @@ export class DisplayAnki { return results; } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {import('display-anki').CreateMode} mode + * @param {import('anki-note-builder').Requirement[]} requirements + * @returns {Promise<import('display-anki').CreateNoteResult>} + */ async _createNote(dictionaryEntry, mode, requirements) { const context = this._noteContext; + if (context === null) { throw new Error('Note context not initialized'); } const modeOptions = this._modeOptions.get(mode); if (typeof modeOptions === 'undefined') { throw new Error(`Unsupported note type: ${mode}`); } const template = this._ankiFieldTemplates; + if (typeof template !== 'string') { throw new Error('Invalid template'); } const {deck: deckName, model: modelName} = modeOptions; const fields = Object.entries(modeOptions.fields); const contentOrigin = this._display.getContentOrigin(); @@ -586,12 +728,26 @@ export class DisplayAnki { return {note, errors, requirements: outputRequirements}; } + /** + * @param {boolean} isTerms + * @returns {import('display-anki').CreateMode[]} + */ _getModes(isTerms) { return isTerms ? ['term-kanji', 'term-kana'] : ['kanji']; } + /** + * @param {unknown} sentence + * @param {string} fallback + * @param {number} fallbackOffset + * @returns {import('anki-templates-internal').ContextSentence} + */ _getValidSentenceData(sentence, fallback, fallbackOffset) { - let {text, offset} = (isObject(sentence) ? sentence : {}); + let text; + let offset; + if (typeof sentence === 'object' && sentence !== null) { + ({text, offset} = /** @type {import('core').UnknownObject} */ (sentence)); + } if (typeof text !== 'string') { text = fallback; offset = fallbackOffset; @@ -601,6 +757,10 @@ export class DisplayAnki { return {text, offset}; } + /** + * @param {import('api').InjectAnkiNoteMediaDefinitionDetails} details + * @returns {?import('anki-note-builder').AudioMediaOptions} + */ _getAnkiNoteMediaAudioDetails(details) { if (details.type !== 'term') { return null; } const {sources, preferredAudioIndex} = this._displayAudio.getAnkiNoteMediaAudioDetails(details.term, details.reading); @@ -609,56 +769,79 @@ export class DisplayAnki { // View note functions + /** + * @param {MouseEvent} e + */ _onViewNoteButtonClick(e) { + const element = /** @type {HTMLElement} */ (e.currentTarget); e.preventDefault(); if (e.shiftKey) { - this._showViewNoteMenu(e.currentTarget); + this._showViewNoteMenu(element); } else { - this._viewNote(e.currentTarget); + this._viewNote(element); } } + /** + * @param {MouseEvent} e + */ _onViewNoteButtonContextMenu(e) { + const element = /** @type {HTMLElement} */ (e.currentTarget); e.preventDefault(); - this._showViewNoteMenu(e.currentTarget); + this._showViewNoteMenu(element); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onViewNoteButtonMenuClose(e) { const {detail: {action, item}} = e; switch (action) { case 'viewNote': - this._viewNote(item); + if (item !== null) { + this._viewNote(item); + } break; } } + /** + * @param {number} index + * @param {number[]} noteIds + * @param {boolean} prepend + */ _updateViewNoteButton(index, noteIds, prepend) { const button = this._getViewNoteButton(index); if (button === null) { return; } + /** @type {(number|string)[]} */ + let allNoteIds = noteIds; if (prepend) { const currentNoteIds = button.dataset.noteIds; if (typeof currentNoteIds === 'string' && currentNoteIds.length > 0) { - noteIds = [...noteIds, currentNoteIds.split(' ')]; + allNoteIds = [...allNoteIds, ...currentNoteIds.split(' ')]; } } - const disabled = (noteIds.length === 0); + const disabled = (allNoteIds.length === 0); button.disabled = disabled; button.hidden = disabled; - button.dataset.noteIds = noteIds.join(' '); + button.dataset.noteIds = allNoteIds.join(' '); - const badge = button.querySelector('.action-button-badge'); + const badge = /** @type {?HTMLElement} */ (button.querySelector('.action-button-badge')); if (badge !== null) { const badgeData = badge.dataset; - if (noteIds.length > 1) { + if (allNoteIds.length > 1) { badgeData.icon = 'plus-thick'; - badgeData.hidden = false; + badge.hidden = false; } else { delete badgeData.icon; - badgeData.hidden = true; + badge.hidden = true; } } } + /** + * @param {HTMLElement} node + */ async _viewNote(node) { const noteIds = this._getNodeNoteIds(node); if (noteIds.length === 0) { return; } @@ -666,26 +849,30 @@ export class DisplayAnki { await yomitan.api.noteView(noteIds[0], this._noteGuiMode, false); } catch (e) { const displayErrors = ( - e.message === 'Mode not supported' ? + e instanceof Error && e.message === 'Mode not supported' ? [this._display.displayGenerator.instantiateTemplateFragment('footer-notification-anki-view-note-error')] : void 0 ); - this._showErrorNotification([e], displayErrors); + this._showErrorNotification([e instanceof Error ? e : new Error(`${e}`)], displayErrors); return; } } + /** + * @param {HTMLElement} node + */ _showViewNoteMenu(node) { const noteIds = this._getNodeNoteIds(node); if (noteIds.length === 0) { return; } - const menuContainerNode = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu'); - const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); + const menuContainerNode = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu')); + const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body')); for (let i = 0, ii = noteIds.length; i < ii; ++i) { const noteId = noteIds[i]; - const item = this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item'); - item.querySelector('.popup-menu-item-label').textContent = `Note ${i + 1}: ${noteId}`; + const item = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('view-note-button-popup-menu-item')); + const label = /** @type {Element} */ (item.querySelector('.popup-menu-item-label')); + label.textContent = `Note ${i + 1}: ${noteId}`; item.dataset.menuAction = 'viewNote'; item.dataset.noteIds = `${noteId}`; menuBodyNode.appendChild(item); @@ -696,6 +883,10 @@ export class DisplayAnki { popupMenu.prepare(); } + /** + * @param {HTMLElement} node + * @returns {number[]} + */ _getNodeNoteIds(node) { const {noteIds} = node.dataset; const results = []; @@ -710,11 +901,16 @@ export class DisplayAnki { return results; } + /** + * @param {number} index + * @returns {?HTMLButtonElement} + */ _getViewNoteButton(index) { const entry = this._getEntry(index); return entry !== null ? entry.querySelector('.action-button[data-action=view-note]') : null; } + /** */ _viewNoteForSelectedEntry() { const index = this._display.selectedIndex; const button = this._getViewNoteButton(index); @@ -722,4 +918,40 @@ export class DisplayAnki { this._viewNote(button); } } + + /** + * @param {string|undefined} value + * @returns {?import('display-anki').CreateMode} + */ + _getValidCreateMode(value) { + switch (value) { + case 'kanji': + case 'term-kanji': + case 'term-kana': + return value; + default: + return null; + } + } +} + +class DisplayAnkiError extends Error { + /** + * @param {string} message + */ + constructor(message) { + super(message); + /** @type {?import('anki-note-builder').Requirement[]} */ + this._requirements = null; + /** @type {?import('anki-note-builder').Requirement[]} */ + this._outputRequirements = null; + } + + /** @type {?import('anki-note-builder').Requirement[]} */ + get requirements() { return this._requirements; } + set requirements(value) { this._requirements = value; } + + /** @type {?import('anki-note-builder').Requirement[]} */ + get outputRequirements() { return this._outputRequirements; } + set outputRequirements(value) { this._outputRequirements = value; } } diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js index faed88bc..3576decb 100644 --- a/ext/js/display/display-audio.js +++ b/ext/js/display/display-audio.js @@ -22,20 +22,37 @@ import {AudioSystem} from '../media/audio-system.js'; import {yomitan} from '../yomitan.js'; export class DisplayAudio { + /** + * @param {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {?import('display-audio').GenericAudio} */ this._audioPlaying = null; + /** @type {AudioSystem} */ this._audioSystem = new AudioSystem(); + /** @type {number} */ this._playbackVolume = 1.0; + /** @type {boolean} */ this._autoPlay = false; + /** @type {?import('core').Timeout} */ this._autoPlayAudioTimer = null; + /** @type {number} */ this._autoPlayAudioDelay = 400; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {Map<string, import('display-audio').CacheItem>} */ this._cache = new Map(); - this._menuContainer = document.querySelector('#popup-menus'); + /** @type {Element} */ + this._menuContainer = /** @type {Element} */ (document.querySelector('#popup-menus')); + /** @type {import('core').TokenObject} */ this._entriesToken = {}; + /** @type {Set<PopupMenu>} */ this._openMenus = new Set(); + /** @type {import('display-audio').AudioSource[]} */ this._audioSources = []; + /** @type {Map<import('settings').AudioSourceType, string>} */ this._audioSourceTypeNames = new Map([ ['jpod101', 'JapanesePod101'], ['jpod101-alternate', 'JapanesePod101 (Alternate)'], @@ -45,11 +62,15 @@ export class DisplayAudio { ['custom', 'Custom URL'], ['custom-json', 'Custom URL (JSON)'] ]); + /** @type {(event: MouseEvent) => void} */ this._onAudioPlayButtonClickBind = this._onAudioPlayButtonClick.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onAudioPlayButtonContextMenuBind = this._onAudioPlayButtonContextMenu.bind(this); + /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */ this._onAudioPlayMenuCloseClickBind = this._onAudioPlayMenuCloseClick.bind(this); } + /** @type {number} */ get autoPlayAudioDelay() { return this._autoPlayAudioDelay; } @@ -58,6 +79,7 @@ export class DisplayAudio { this._autoPlayAudioDelay = value; } + /** */ prepare() { this._audioSystem.prepare(); this._display.hotkeyHandler.registerActions([ @@ -72,21 +94,31 @@ export class DisplayAudio { this._display.on('contentUpdateEntry', this._onContentUpdateEntry.bind(this)); this._display.on('contentUpdateComplete', this._onContentUpdateComplete.bind(this)); this._display.on('frameVisibilityChange', this._onFrameVisibilityChange.bind(this)); - this._onOptionsUpdated({options: this._display.getOptions()}); + const options = this._display.getOptions(); + if (options !== null) { + this._onOptionsUpdated({options}); + } } + /** */ clearAutoPlayTimer() { if (this._autoPlayAudioTimer === null) { return; } clearTimeout(this._autoPlayAudioTimer); this._autoPlayAudioTimer = null; } + /** */ stopAudio() { if (this._audioPlaying === null) { return; } this._audioPlaying.pause(); this._audioPlaying = null; } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @param {?string} [sourceType] + */ async playAudio(dictionaryEntryIndex, headwordIndex, sourceType=null) { let sources = this._audioSources; if (sourceType !== null) { @@ -100,7 +132,13 @@ export class DisplayAudio { await this._playAudio(dictionaryEntryIndex, headwordIndex, sources, null); } + /** + * @param {string} term + * @param {string} reading + * @returns {import('display-audio').AudioMediaOptions} + */ getAnkiNoteMediaAudioDetails(term, reading) { + /** @type {import('display-audio').AudioSourceShort[]} */ const sources = []; let preferredAudioIndex = null; const primaryCardAudio = this._getPrimaryCardAudio(term, reading); @@ -120,17 +158,21 @@ export class DisplayAudio { // Private + /** + * @param {import('display').OptionsUpdatedEvent} details + */ _onOptionsUpdated({options}) { - if (options === null) { return; } const {enabled, autoPlay, volume, sources} = options.audio; this._autoPlay = enabled && autoPlay; this._playbackVolume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; + /** @type {Set<import('settings').AudioSourceType>} */ const requiredAudioSources = new Set([ 'jpod101', 'jpod101-alternate', 'jisho' ]); + /** @type {Map<string, import('display-audio').AudioSource[]>} */ const nameMap = new Map(); this._audioSources.length = 0; for (const {type, url, voice} of sources) { @@ -147,6 +189,7 @@ export class DisplayAudio { this._cache.clear(); } + /** */ _onContentClear() { this._entriesToken = {}; this._cache.clear(); @@ -154,6 +197,9 @@ export class DisplayAudio { this._eventListeners.removeAllEventListeners(); } + /** + * @param {import('display').ContentUpdateEntryEvent} details + */ _onContentUpdateEntry({element}) { const eventListeners = this._eventListeners; for (const button of element.querySelectorAll('.action-button[data-action=play-audio]')) { @@ -163,6 +209,7 @@ export class DisplayAudio { } } + /** */ _onContentUpdateComplete() { if (!this._autoPlay || !this._display.frameVisible) { return; } @@ -186,6 +233,9 @@ export class DisplayAudio { } } + /** + * @param {import('display').FrameVisibilityChangeEvent} details + */ _onFrameVisibilityChange({value}) { if (!value) { // The auto-play timer is stopped, but any audio that has already started playing @@ -194,18 +244,31 @@ export class DisplayAudio { } } + /** */ _onHotkeyActionPlayAudio() { this.playAudio(this._display.selectedIndex, 0); } + /** + * @param {unknown} source + */ _onHotkeyActionPlayAudioFromSource(source) { + if (!(typeof source === 'string' || typeof source === 'undefined' || source === null)) { return; } this.playAudio(this._display.selectedIndex, 0, source); } + /** */ _onMessageClearAutoPlayTimer() { this.clearAutoPlayTimer(); } + /** + * @param {import('settings').AudioSourceType} type + * @param {string} url + * @param {string} voice + * @param {boolean} isInOptions + * @param {Map<string, import('display-audio').AudioSource[]>} nameMap + */ _addAudioSourceInfo(type, url, voice, isInOptions, nameMap) { const index = this._audioSources.length; const downloadable = this._sourceIsDownloadable(type); @@ -222,6 +285,7 @@ export class DisplayAudio { entries[0].nameUnique = false; } + /** @type {import('display-audio').AudioSource} */ const source = { index, type, @@ -238,32 +302,41 @@ export class DisplayAudio { this._audioSources.push(source); } + /** + * @param {MouseEvent} e + */ _onAudioPlayButtonClick(e) { e.preventDefault(); - const button = e.currentTarget; + const button = /** @type {HTMLButtonElement} */ (e.currentTarget); const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button); const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button); if (e.shiftKey) { - this._showAudioMenu(e.currentTarget, dictionaryEntryIndex, headwordIndex); + this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex); } else { this.playAudio(dictionaryEntryIndex, headwordIndex); } } + /** + * @param {MouseEvent} e + */ _onAudioPlayButtonContextMenu(e) { e.preventDefault(); - const button = e.currentTarget; + const button = /** @type {HTMLButtonElement} */ (e.currentTarget); const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button); const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button); - this._showAudioMenu(e.currentTarget, dictionaryEntryIndex, headwordIndex); + this._showAudioMenu(button, dictionaryEntryIndex, headwordIndex); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onAudioPlayMenuCloseClick(e) { - const button = e.currentTarget; + const button = /** @type {Element} */ (e.currentTarget); const headwordIndex = this._getAudioPlayButtonHeadwordIndex(button); const dictionaryEntryIndex = this._display.getElementDictionaryEntryIndex(button); @@ -282,6 +355,12 @@ export class DisplayAudio { } } + /** + * @param {string} term + * @param {string} reading + * @param {boolean} create + * @returns {import('display-audio').CacheItem|undefined} + */ _getCacheItem(term, reading, create) { const key = this._getTermReadingKey(term, reading); let cacheEntry = this._cache.get(key); @@ -295,31 +374,41 @@ export class DisplayAudio { return cacheEntry; } + /** + * @param {Element} item + * @returns {import('display-audio').SourceInfo} + */ _getMenuItemSourceInfo(item) { - const group = item.closest('.popup-menu-item-group'); + const group = /** @type {?HTMLElement} */ (item.closest('.popup-menu-item-group')); if (group !== null) { - let {index, subIndex} = group.dataset; - index = Number.parseInt(index, 10); - if (index >= 0 && index < this._audioSources.length) { - const source = this._audioSources[index]; - if (typeof subIndex === 'string') { - subIndex = Number.parseInt(subIndex, 10); - } else { - subIndex = null; + const {index, subIndex} = group.dataset; + if (typeof index === 'string') { + const indexNumber = Number.parseInt(index, 10); + if (indexNumber >= 0 && indexNumber < this._audioSources.length) { + return { + source: this._audioSources[indexNumber], + subIndex: typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null + }; } - return {source, subIndex}; } } return {source: null, subIndex: null}; } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @param {import('display-audio').AudioSource[]} sources + * @param {?number} audioInfoListIndex + * @returns {Promise<import('display-audio').PlayAudioResult>} + */ async _playAudio(dictionaryEntryIndex, headwordIndex, sources, audioInfoListIndex) { this.stopAudio(); this.clearAutoPlayTimer(); const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex); if (headword === null) { - return {audio: null, source: null, valid: false}; + return {audio: null, source: null, subIndex: 0, valid: false}; } const buttons = this._getAudioPlayButtons(dictionaryEntryIndex, headwordIndex); @@ -377,7 +466,13 @@ export class DisplayAudio { } } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @param {?HTMLElement} item + */ async _playAudioFromSource(dictionaryEntryIndex, headwordIndex, item) { + if (item === null) { return; } const {source, subIndex} = this._getMenuItemSourceInfo(item); if (source === null) { return; } @@ -392,7 +487,15 @@ export class DisplayAudio { } } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @param {?HTMLElement} item + * @param {?PopupMenu} menu + * @param {boolean} canToggleOff + */ _setPrimaryAudio(dictionaryEntryIndex, headwordIndex, item, menu, canToggleOff) { + if (item === null) { return; } const {source, subIndex} = this._getMenuItemSourceInfo(item); if (source === null || !source.downloadable) { return; } @@ -402,6 +505,7 @@ export class DisplayAudio { const {index} = source; const {term, reading} = headword; const cacheEntry = this._getCacheItem(term, reading, true); + if (typeof cacheEntry === 'undefined') { return; } let {primaryCardAudio} = cacheEntry; primaryCardAudio = ( @@ -417,39 +521,59 @@ export class DisplayAudio { } } + /** + * @param {Element} button + * @returns {number} + */ _getAudioPlayButtonHeadwordIndex(button) { - const headwordNode = button.closest('.headword'); + const headwordNode = /** @type {?HTMLElement} */ (button.closest('.headword')); if (headwordNode !== null) { - const headwordIndex = parseInt(headwordNode.dataset.index, 10); - if (Number.isFinite(headwordIndex)) { return headwordIndex; } + const {index} = headwordNode.dataset; + if (typeof index === 'string') { + const headwordIndex = parseInt(index, 10); + if (Number.isFinite(headwordIndex)) { return headwordIndex; } + } } return 0; } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @returns {HTMLButtonElement[]} + */ _getAudioPlayButtons(dictionaryEntryIndex, headwordIndex) { const results = []; const {dictionaryEntryNodes} = this._display; if (dictionaryEntryIndex >= 0 && dictionaryEntryIndex < dictionaryEntryNodes.length) { const node = dictionaryEntryNodes[dictionaryEntryIndex]; - const button1 = (headwordIndex === 0 ? node.querySelector('.action-button[data-action=play-audio]') : null); - const button2 = node.querySelector(`.headword:nth-of-type(${headwordIndex + 1}) .action-button[data-action=play-audio]`); + const button1 = /** @type {?HTMLButtonElement} */ ((headwordIndex === 0 ? node.querySelector('.action-button[data-action=play-audio]') : null)); + const button2 = /** @type {?HTMLButtonElement} */ (node.querySelector(`.headword:nth-of-type(${headwordIndex + 1}) .action-button[data-action=play-audio]`)); if (button1 !== null) { results.push(button1); } if (button2 !== null) { results.push(button2); } } return results; } + /** + * @param {string} term + * @param {string} reading + * @param {import('display-audio').AudioSource[]} sources + * @param {?number} audioInfoListIndex + * @returns {Promise<?import('display-audio').TermAudio>} + */ async _createTermAudio(term, reading, sources, audioInfoListIndex) { - const {sourceMap} = this._getCacheItem(term, reading, true); + const cacheItem = this._getCacheItem(term, reading, true); + if (typeof cacheItem === 'undefined') { return null; } + const {sourceMap} = cacheItem; for (const source of sources) { const {index} = source; let cacheUpdated = false; - let infoListPromise; let sourceInfo = sourceMap.get(index); if (typeof sourceInfo === 'undefined') { - infoListPromise = this._getTermAudioInfoList(source, term, reading); + const infoListPromise = this._getTermAudioInfoList(source, term, reading); sourceInfo = {infoListPromise, infoList: null}; sourceMap.set(index, sourceInfo); cacheUpdated = true; @@ -457,7 +581,7 @@ export class DisplayAudio { let {infoList} = sourceInfo; if (infoList === null) { - infoList = await infoListPromise; + infoList = await sourceInfo.infoListPromise; sourceInfo.infoList = infoList; } @@ -471,6 +595,12 @@ export class DisplayAudio { return null; } + /** + * @param {import('display-audio').AudioSource} source + * @param {import('display-audio').AudioInfoList} infoList + * @param {?number} audioInfoListIndex + * @returns {Promise<import('display-audio').CreateAudioResult>} + */ async _createAudioFromInfoList(source, infoList, audioInfoListIndex) { let start = 0; let end = infoList.length; @@ -479,6 +609,7 @@ export class DisplayAudio { end = Math.max(0, Math.min(end, audioInfoListIndex + 1)); } + /** @type {import('display-audio').CreateAudioResult} */ const result = { audio: null, index: -1, @@ -518,6 +649,11 @@ export class DisplayAudio { return result; } + /** + * @param {import('audio-downloader').Info} info + * @param {import('display-audio').AudioSource} source + * @returns {Promise<import('display-audio').GenericAudio>} + */ async _createAudioFromInfo(info, source) { switch (info.type) { case 'url': @@ -525,16 +661,27 @@ export class DisplayAudio { case 'tts': return this._audioSystem.createTextToSpeechAudio(info.text, info.voice); default: - throw new Error(`Unsupported type: ${info.type}`); + throw new Error(`Unsupported type: ${/** @type {import('core').SafeAny} */ (info).type}`); } } + /** + * @param {import('display-audio').AudioSource} source + * @param {string} term + * @param {string} reading + * @returns {Promise<import('display-audio').AudioInfoList>} + */ async _getTermAudioInfoList(source, term, reading) { const sourceData = this._getSourceData(source); const infoList = await yomitan.api.getTermAudioInfoList(sourceData, term, reading); return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null})); } + /** + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + * @returns {?import('dictionary').TermHeadword} + */ _getHeadword(dictionaryEntryIndex, headwordIndex) { const {dictionaryEntries} = this._display; if (dictionaryEntryIndex < 0 || dictionaryEntryIndex >= dictionaryEntries.length) { return null; } @@ -548,10 +695,19 @@ export class DisplayAudio { return headwords[headwordIndex]; } + /** + * @param {string} term + * @param {string} reading + * @returns {string} + */ _getTermReadingKey(term, reading) { return JSON.stringify([term, reading]); } + /** + * @param {HTMLButtonElement} button + * @param {?number} potentialAvailableAudioCount + */ _updateAudioPlayButtonBadge(button, potentialAvailableAudioCount) { if (potentialAvailableAudioCount === null) { delete button.dataset.potentialAvailableAudioCount; @@ -559,27 +715,32 @@ export class DisplayAudio { button.dataset.potentialAvailableAudioCount = `${potentialAvailableAudioCount}`; } - const badge = button.querySelector('.action-button-badge'); + const badge = /** @type {?HTMLElement} */ (button.querySelector('.action-button-badge')); if (badge === null) { return; } const badgeData = badge.dataset; switch (potentialAvailableAudioCount) { case 0: badgeData.icon = 'cross'; - badgeData.hidden = false; + badge.hidden = false; break; case 1: case null: delete badgeData.icon; - badgeData.hidden = true; + badge.hidden = true; break; default: badgeData.icon = 'plus-thick'; - badgeData.hidden = false; + badge.hidden = false; break; } } + /** + * @param {string} term + * @param {string} reading + * @returns {?number} + */ _getPotentialAvailableAudioCount(term, reading) { const cacheEntry = this._getCacheItem(term, reading, false); if (typeof cacheEntry === 'undefined') { return null; } @@ -597,6 +758,11 @@ export class DisplayAudio { return count; } + /** + * @param {HTMLButtonElement} button + * @param {number} dictionaryEntryIndex + * @param {number} headwordIndex + */ _showAudioMenu(button, dictionaryEntryIndex, headwordIndex) { const headword = this._getHeadword(dictionaryEntryIndex, headwordIndex); if (headword === null) { return; } @@ -608,10 +774,17 @@ export class DisplayAudio { popupMenu.on('close', this._onPopupMenuClose.bind(this)); } + /** + * @param {import('popup-menu').MenuCloseEventDetails} details + */ _onPopupMenuClose({menu}) { this._openMenus.delete(menu); } + /** + * @param {import('settings').AudioSourceType} source + * @returns {boolean} + */ _sourceIsDownloadable(source) { switch (source) { case 'text-to-speech': @@ -622,10 +795,16 @@ export class DisplayAudio { } } + /** + * @param {HTMLButtonElement} sourceButton + * @param {string} term + * @param {string} reading + * @returns {PopupMenu} + */ _createMenu(sourceButton, term, reading) { // Create menu - const menuContainerNode = this._display.displayGenerator.instantiateTemplate('audio-button-popup-menu'); - const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); + const menuContainerNode = /** @type {HTMLElement} */ (this._display.displayGenerator.instantiateTemplate('audio-button-popup-menu')); + const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body')); menuContainerNode.dataset.term = term; menuContainerNode.dataset.reading = reading; @@ -640,6 +819,12 @@ export class DisplayAudio { return new PopupMenu(sourceButton, menuContainerNode); } + /** + * @param {HTMLElement} menuContainerNode + * @param {HTMLElement} menuItemContainer + * @param {string} term + * @param {string} reading + */ _createMenuItems(menuContainerNode, menuItemContainer, term, reading) { const {displayGenerator} = this._display; let showIcons = false; @@ -649,12 +834,10 @@ export class DisplayAudio { const entries = this._getMenuItemEntries(source, term, reading); for (let i = 0, ii = entries.length; i < ii; ++i) { const {valid, index: subIndex, name: subName} = entries[i]; - let node = this._getOrCreateMenuItem(currentItems, index, subIndex); - if (node === null) { - node = displayGenerator.instantiateTemplate('audio-button-popup-menu-item'); - } + const existingNode = this._getOrCreateMenuItem(currentItems, index, subIndex); + const node = existingNode !== null ? existingNode : /** @type {HTMLElement} */ (displayGenerator.instantiateTemplate('audio-button-popup-menu-item')); - const labelNode = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label'); + const labelNode = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label')); let label = name; if (!nameUnique) { label = `${label} ${nameIndex + 1}`; @@ -664,11 +847,11 @@ export class DisplayAudio { if (typeof subName === 'string' && subName.length > 0) { label += `: ${subName}`; } labelNode.textContent = label; - const cardButton = node.querySelector('.popup-menu-item-set-primary-audio-button'); + const cardButton = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-set-primary-audio-button')); cardButton.hidden = !downloadable; if (valid !== null) { - const icon = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-icon'); + const icon = /** @type {HTMLElement} */ (node.querySelector('.popup-menu-item-audio-button .popup-menu-item-icon')); icon.dataset.icon = valid ? 'checkmark' : 'cross'; showIcons = true; } @@ -691,16 +874,22 @@ export class DisplayAudio { menuContainerNode.dataset.showIcons = `${showIcons}`; } + /** + * @param {Element[]} currentItems + * @param {number} index + * @param {?number} subIndex + * @returns {?HTMLElement} + */ _getOrCreateMenuItem(currentItems, index, subIndex) { - index = `${index}`; - subIndex = `${subIndex !== null ? subIndex : 0}`; + const indexNumber = `${index}`; + const subIndexNumber = `${subIndex !== null ? subIndex : 0}`; for (let i = 0, ii = currentItems.length; i < ii; ++i) { const node = currentItems[i]; - if (index !== node.dataset.index) { continue; } + if (!(node instanceof HTMLElement) || indexNumber !== node.dataset.index) { continue; } let subIndex2 = node.dataset.subIndex; if (typeof subIndex2 === 'undefined') { subIndex2 = '0'; } - if (subIndex !== subIndex2) { continue; } + if (subIndexNumber !== subIndex2) { continue; } currentItems.splice(i, 1); return node; @@ -708,6 +897,12 @@ export class DisplayAudio { return null; } + /** + * @param {import('display-audio').AudioSource} source + * @param {string} term + * @param {string} reading + * @returns {import('display-audio').MenuItemEntry[]} + */ _getMenuItemEntries(source, term, reading) { const cacheEntry = this._getCacheItem(term, reading, false); if (typeof cacheEntry !== 'undefined') { @@ -721,11 +916,12 @@ export class DisplayAudio { return [{valid: false, index: null, name: null}]; } + /** @type {import('display-audio').MenuItemEntry[]} */ const results = []; for (let i = 0; i < ii; ++i) { const {audio, audioResolved, info: {name}} = infoList[i]; const valid = audioResolved ? (audio !== null) : null; - const entry = {valid, index: i, name}; + const entry = {valid, index: i, name: typeof name === 'string' ? name : null}; results.push(entry); } return results; @@ -735,34 +931,52 @@ export class DisplayAudio { return [{valid: null, index: null, name: null}]; } + /** + * @param {string} term + * @param {string} reading + * @returns {?import('display-audio').PrimaryCardAudio} + */ _getPrimaryCardAudio(term, reading) { const cacheEntry = this._getCacheItem(term, reading, false); return typeof cacheEntry !== 'undefined' ? cacheEntry.primaryCardAudio : null; } + /** + * @param {HTMLElement} menuBodyNode + * @param {string} term + * @param {string} reading + */ _updateMenuPrimaryCardAudio(menuBodyNode, term, reading) { const primaryCardAudio = this._getPrimaryCardAudio(term, reading); const primaryCardAudioIndex = (primaryCardAudio !== null ? primaryCardAudio.index : null); const primaryCardAudioSubIndex = (primaryCardAudio !== null ? primaryCardAudio.subIndex : null); - const itemGroups = menuBodyNode.querySelectorAll('.popup-menu-item-group'); + const itemGroups = /** @type {NodeListOf<HTMLElement>} */ (menuBodyNode.querySelectorAll('.popup-menu-item-group')); for (const node of itemGroups) { - let {index, subIndex} = node.dataset; - index = Number.parseInt(index, 10); - subIndex = typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null; - const isPrimaryCardAudio = (index === primaryCardAudioIndex && subIndex === primaryCardAudioSubIndex); + const {index, subIndex} = node.dataset; + if (typeof index !== 'string') { continue; } + const indexNumber = Number.parseInt(index, 10); + const subIndexNumber = typeof subIndex === 'string' ? Number.parseInt(subIndex, 10) : null; + const isPrimaryCardAudio = (indexNumber === primaryCardAudioIndex && subIndexNumber === primaryCardAudioSubIndex); node.dataset.isPrimaryCardAudio = `${isPrimaryCardAudio}`; } } + /** */ _updateOpenMenu() { for (const menu of this._openMenus) { const menuContainerNode = menu.containerNode; const {term, reading} = menuContainerNode.dataset; - this._createMenuItems(menuContainerNode, menu.bodyNode, term, reading); + if (typeof term === 'string' && typeof reading === 'string') { + this._createMenuItems(menuContainerNode, menu.bodyNode, term, reading); + } menu.updatePosition(); } } + /** + * @param {import('display-audio').AudioSource} source + * @returns {import('display-audio').AudioSourceShort} + */ _getSourceData(source) { const {type, url, voice} = source; return {type, url, voice}; diff --git a/ext/js/display/display-content-manager.js b/ext/js/display/display-content-manager.js index fb2e7db5..c78ef7e8 100644 --- a/ext/js/display/display-content-manager.js +++ b/ext/js/display/display-content-manager.js @@ -21,30 +21,23 @@ import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; import {yomitan} from '../yomitan.js'; /** - * A callback used when a media file has been loaded. - * @callback DisplayContentManager.OnLoadCallback - * @param {string} url The URL of the media that was loaded. - */ - -/** - * A callback used when a media file should be unloaded. - * @callback DisplayContentManager.OnUnloadCallback - * @param {boolean} fullyLoaded Whether or not the media was fully loaded. - */ - -/** * The content manager which is used when generating HTML display content. */ export class DisplayContentManager { /** * Creates a new instance of the class. - * @param {Display} display The display instance that owns this object. + * @param {import('./display.js').Display} display The display instance that owns this object. */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {import('core').TokenObject} */ this._token = {}; + /** @type {Map<string, Map<string, Promise<?import('display-content-manager').CachedMediaDataLoaded>>>} */ this._mediaCache = new Map(); + /** @type {import('display-content-manager').LoadMediaDataInfo[]} */ this._loadMediaData = []; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } @@ -52,9 +45,9 @@ export class DisplayContentManager { * Attempts to load the media file from a given dictionary. * @param {string} path The path to the media file in the dictionary. * @param {string} dictionary The name of the dictionary. - * @param {DisplayContentManager.OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. + * @param {import('display-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. * No assumptions should be made about the synchronicity of this callback. - * @param {DisplayContentManager.OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. + * @param {import('display-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. */ loadMedia(path, dictionary, onLoad, onUnload) { this._loadMedia(path, dictionary, onLoad, onUnload); @@ -72,10 +65,8 @@ export class DisplayContentManager { this._loadMediaData = []; for (const map of this._mediaCache.values()) { - for (const {url} of map.values()) { - if (url !== null) { - URL.revokeObjectURL(url); - } + for (const result of map.values()) { + this._revokeUrl(result); } } this._mediaCache.clear(); @@ -87,7 +78,7 @@ export class DisplayContentManager { /** * Sets up attributes and events for a link element. - * @param {Element} element The link element. + * @param {HTMLAnchorElement} element The link element. * @param {string} href The URL. * @param {boolean} internal Whether or not the URL is an internal or external link. */ @@ -100,57 +91,71 @@ export class DisplayContentManager { this._eventListeners.addEventListener(element, 'click', this._onLinkClick.bind(this)); } + /** + * @param {string} path + * @param {string} dictionary + * @param {import('display-content-manager').OnLoadCallback} onLoad + * @param {import('display-content-manager').OnUnloadCallback} onUnload + */ async _loadMedia(path, dictionary, onLoad, onUnload) { const token = this._token; - const data = {onUnload, loaded: false}; - - this._loadMediaData.push(data); - const media = await this._getMedia(path, dictionary); - if (token !== this._token) { return; } + if (token !== this._token || media === null) { return; } + /** @type {import('display-content-manager').LoadMediaDataInfo} */ + const data = {onUnload, loaded: false}; + this._loadMediaData.push(data); onLoad(media.url); data.loaded = true; } - async _getMedia(path, dictionary) { - let cachedData; + /** + * @param {string} path + * @param {string} dictionary + * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} + */ + _getMedia(path, dictionary) { + /** @type {Promise<?import('display-content-manager').CachedMediaDataLoaded>|undefined} */ + let promise; let dictionaryCache = this._mediaCache.get(dictionary); if (typeof dictionaryCache !== 'undefined') { - cachedData = dictionaryCache.get(path); + promise = dictionaryCache.get(path); } else { dictionaryCache = new Map(); this._mediaCache.set(dictionary, dictionaryCache); } - if (typeof cachedData === 'undefined') { - cachedData = { - promise: null, - data: null, - url: null - }; - dictionaryCache.set(path, cachedData); - cachedData.promise = this._getMediaData(path, dictionary, cachedData); + if (typeof promise === 'undefined') { + promise = this._getMediaData(path, dictionary); + dictionaryCache.set(path, promise); } - return cachedData.promise; + return promise; } - async _getMediaData(path, dictionary, cachedData) { + /** + * @param {string} path + * @param {string} dictionary + * @returns {Promise<?import('display-content-manager').CachedMediaDataLoaded>} + */ + async _getMediaData(path, dictionary) { const token = this._token; - const data = (await yomitan.api.getMedia([{path, dictionary}]))[0]; - if (token === this._token && data !== null) { + const datas = await yomitan.api.getMedia([{path, dictionary}]); + if (token === this._token && datas.length > 0) { + const data = datas[0]; const buffer = ArrayBufferUtil.base64ToArrayBuffer(data.content); const blob = new Blob([buffer], {type: data.mediaType}); const url = URL.createObjectURL(blob); - cachedData.data = data; - cachedData.url = url; + return {data, url}; } - return cachedData; + return null; } + /** + * @param {MouseEvent} e + */ _onLinkClick(e) { - const {href} = e.currentTarget; + const {href} = /** @type {HTMLAnchorElement} */ (e.currentTarget); if (typeof href !== 'string') { return; } const baseUrl = new URL(location.href); @@ -160,6 +165,7 @@ export class DisplayContentManager { e.preventDefault(); + /** @type {import('display').HistoryParams} */ const params = {}; for (const [key, value] of url.searchParams.entries()) { params[key] = value; @@ -172,4 +178,13 @@ export class DisplayContentManager { content: null }); } + + /** + * @param {Promise<?import('display-content-manager').CachedMediaDataLoaded>} data + */ + async _revokeUrl(data) { + const result = await data; + if (result === null) { return; } + URL.revokeObjectURL(result.url); + } } diff --git a/ext/js/display/display-generator.js b/ext/js/display/display-generator.js index e8a2104f..eb464001 100644 --- a/ext/js/display/display-generator.js +++ b/ext/js/display/display-generator.js @@ -17,6 +17,7 @@ */ import {isObject} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {HtmlTemplateCollection} from '../dom/html-template-collection.js'; import {DictionaryDataUtil} from '../language/sandbox/dictionary-data-util.js'; import {yomitan} from '../yomitan.js'; @@ -24,21 +25,32 @@ import {PronunciationGenerator} from './sandbox/pronunciation-generator.js'; import {StructuredContentGenerator} from './sandbox/structured-content-generator.js'; export class DisplayGenerator { + /** + * @param {import('display').DisplayGeneratorConstructorDetails} details + */ constructor({japaneseUtil, contentManager, hotkeyHelpController=null}) { + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('./display-content-manager.js').DisplayContentManager} */ this._contentManager = contentManager; + /** @type {?import('../input/hotkey-help-controller.js').HotkeyHelpController} */ this._hotkeyHelpController = hotkeyHelpController; - this._templates = null; + /** @type {HtmlTemplateCollection} */ + this._templates = new HtmlTemplateCollection(); + /** @type {StructuredContentGenerator} */ this._structuredContentGenerator = new StructuredContentGenerator(this._contentManager, japaneseUtil, document); + /** @type {PronunciationGenerator} */ this._pronunciationGenerator = new PronunciationGenerator(japaneseUtil); } + /** */ async prepare() { const html = await yomitan.api.getDisplayTemplatesHtml(); - this._templates = new HtmlTemplateCollection(html); + this._templates.load(html); this.updateHotkeys(); } + /** */ updateHotkeys() { const hotkeyHelpController = this._hotkeyHelpController; if (hotkeyHelpController === null) { return; } @@ -47,15 +59,19 @@ export class DisplayGenerator { } } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {HTMLElement} + */ createTermEntry(dictionaryEntry) { - const node = this._templates.instantiate('term-entry'); + const node = this._instantiate('term-entry'); - const headwordsContainer = node.querySelector('.headword-list'); - const inflectionsContainer = node.querySelector('.inflection-list'); - const groupedPronunciationsContainer = node.querySelector('.pronunciation-group-list'); - const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); - const definitionsContainer = node.querySelector('.definition-list'); - const headwordTagsContainer = node.querySelector('.headword-list-tag-list'); + const headwordsContainer = this._querySelector(node, '.headword-list'); + const inflectionsContainer = this._querySelector(node, '.inflection-list'); + const groupedPronunciationsContainer = this._querySelector(node, '.pronunciation-group-list'); + const frequencyGroupListContainer = this._querySelector(node, '.frequency-group-list'); + const definitionsContainer = this._querySelector(node, '.definition-list'); + const headwordTagsContainer = this._querySelector(node, '.headword-list-tag-list'); const {headwords, type, inflections, definitions, frequencies, pronunciations} = dictionaryEntry; const groupedPronunciations = DictionaryDataUtil.getGroupedPronunciations(dictionaryEntry); @@ -63,8 +79,11 @@ export class DisplayGenerator { const groupedFrequencies = DictionaryDataUtil.groupTermFrequencies(dictionaryEntry); const termTags = DictionaryDataUtil.groupTermTags(dictionaryEntry); + /** @type {Set<string>} */ const uniqueTerms = new Set(); + /** @type {Set<string>} */ const uniqueReadings = new Set(); + /** @type {Set<import('dictionary').TermSourceMatchType>} */ const primaryMatchTypes = new Set(); for (const {term, reading, sources} of headwords) { uniqueTerms.add(term); @@ -107,16 +126,16 @@ export class DisplayGenerator { } // Add definitions - const dictionaryTag = this._createDictionaryTag(null); + const dictionaryTag = this._createDictionaryTag(''); for (let i = 0, ii = definitions.length; i < ii; ++i) { const definition = definitions[i]; const {dictionary} = definition; - if (dictionaryTag.dictionary === dictionary) { + if (dictionaryTag.dictionaries.includes(dictionary)) { dictionaryTag.redundant = true; } else { dictionaryTag.redundant = false; - dictionaryTag.dictionary = dictionary; + dictionaryTag.dictionaries.push(dictionary); dictionaryTag.name = dictionary; } @@ -129,19 +148,23 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary').KanjiDictionaryEntry} dictionaryEntry + * @returns {HTMLElement} + */ createKanjiEntry(dictionaryEntry) { - const node = this._templates.instantiate('kanji-entry'); - - const glyphContainer = node.querySelector('.kanji-glyph'); - const frequencyGroupListContainer = node.querySelector('.frequency-group-list'); - const tagContainer = node.querySelector('.kanji-tag-list'); - const definitionsContainer = node.querySelector('.kanji-gloss-list'); - const chineseReadingsContainer = node.querySelector('.kanji-readings-chinese'); - const japaneseReadingsContainer = node.querySelector('.kanji-readings-japanese'); - const statisticsContainer = node.querySelector('.kanji-statistics'); - const classificationsContainer = node.querySelector('.kanji-classifications'); - const codepointsContainer = node.querySelector('.kanji-codepoints'); - const dictionaryIndicesContainer = node.querySelector('.kanji-dictionary-indices'); + const node = this._instantiate('kanji-entry'); + + const glyphContainer = this._querySelector(node, '.kanji-glyph'); + const frequencyGroupListContainer = this._querySelector(node, '.frequency-group-list'); + const tagContainer = this._querySelector(node, '.kanji-tag-list'); + const definitionsContainer = this._querySelector(node, '.kanji-gloss-list'); + const chineseReadingsContainer = this._querySelector(node, '.kanji-readings-chinese'); + const japaneseReadingsContainer = this._querySelector(node, '.kanji-readings-japanese'); + const statisticsContainer = this._querySelector(node, '.kanji-statistics'); + const classificationsContainer = this._querySelector(node, '.kanji-classifications'); + const codepointsContainer = this._querySelector(node, '.kanji-codepoints'); + const dictionaryIndicesContainer = this._querySelector(node, '.kanji-dictionary-indices'); this._setTextContent(glyphContainer, dictionaryEntry.character, 'ja'); const groupedFrequencies = DictionaryDataUtil.groupKanjiFrequencies(dictionaryEntry.frequencies); @@ -162,27 +185,36 @@ export class DisplayGenerator { return node; } + /** + * @returns {HTMLElement} + */ createEmptyFooterNotification() { - return this._templates.instantiate('footer-notification'); + return this._instantiate('footer-notification'); } + /** + * @param {HTMLElement} tagNode + * @param {?import('dictionary').DictionaryEntry} dictionaryEntry + * @returns {DocumentFragment} + */ createTagFooterNotificationDetails(tagNode, dictionaryEntry) { const node = this._templates.instantiateFragment('footer-notification-tag-details'); let details = tagNode.dataset.details; if (typeof details !== 'string') { const label = tagNode.querySelector('.tag-label-content'); - details = label !== null ? label.textContent : ''; + details = label !== null && label.textContent !== null ? label.textContent : ''; } - this._setTextContent(node.querySelector('.tag-details'), details); + const tagDetails = this._querySelector(node, '.tag-details'); + this._setTextContent(tagDetails, details); - if (dictionaryEntry !== null) { + if (dictionaryEntry !== null && dictionaryEntry.type === 'term') { const {headwords} = dictionaryEntry; const disambiguationHeadwords = []; const {headwords: headwordIndices} = tagNode.dataset; if (typeof headwordIndices === 'string' && headwordIndices.length > 0) { - for (let headwordIndex of headwordIndices.split(' ')) { - headwordIndex = Number.parseInt(headwordIndex, 10); + for (const headwordIndexString of headwordIndices.split(' ')) { + const headwordIndex = Number.parseInt(headwordIndexString, 10); if (!Number.isNaN(headwordIndex) && headwordIndex >= 0 && headwordIndex < headwords.length) { disambiguationHeadwords.push(headwords[headwordIndex]); } @@ -190,7 +222,7 @@ export class DisplayGenerator { } if (disambiguationHeadwords.length > 0 && disambiguationHeadwords.length < headwords.length) { - const disambiguationContainer = node.querySelector('.tag-details-disambiguation-list'); + const disambiguationContainer = this._querySelector(node, '.tag-details-disambiguation-list'); const copyAttributes = ['totalHeadwordCount', 'matchedHeadwordCount', 'unmatchedHeadwordCount']; for (const attribute of copyAttributes) { const value = tagNode.dataset[attribute]; @@ -211,13 +243,17 @@ export class DisplayGenerator { return node; } + /** + * @param {(DocumentFragment|Node|Error)[]} errors + * @returns {HTMLElement} + */ createAnkiNoteErrorsNotificationContent(errors) { - const content = this._templates.instantiate('footer-notification-anki-errors-content'); + const content = this._instantiate('footer-notification-anki-errors-content'); - const header = content.querySelector('.anki-note-error-header'); + const header = this._querySelector(content, '.anki-note-error-header'); this._setTextContent(header, (errors.length === 1 ? 'An error occurred:' : `${errors.length} errors occurred:`), 'en'); - const list = content.querySelector('.anki-note-error-list'); + const list = this._querySelector(content, '.anki-note-error-list'); for (const error of errors) { const div = document.createElement('li'); div.className = 'anki-note-error-message'; @@ -226,11 +262,11 @@ export class DisplayGenerator { } else { let message = isObject(error) && typeof error.message === 'string' ? error.message : `${error}`; let link = null; - if (isObject(error) && isObject(error.data)) { - const {referenceUrl} = error.data; + if (error instanceof ExtensionError && error.data !== null && typeof error.data === 'object') { + const {referenceUrl} = /** @type {import('core').UnknownObject} */ (error.data); if (typeof referenceUrl === 'string') { message = message.trimEnd(); - if (!/[.!?]^/.test()) { message += '.'; } + if (!/[.!?]^/.test(message)) { message += '.'; } message += ' '; link = document.createElement('a'); link.href = referenceUrl; @@ -248,20 +284,37 @@ export class DisplayGenerator { return content; } + /** + * @returns {HTMLElement} + */ createProfileListItem() { - return this._templates.instantiate('profile-list-item'); + return this._instantiate('profile-list-item'); } + /** + * @param {string} name + * @returns {HTMLElement} + */ instantiateTemplate(name) { - return this._templates.instantiate(name); + return this._instantiate(name); } + /** + * @param {string} name + * @returns {DocumentFragment} + */ instantiateTemplateFragment(name) { return this._templates.instantiateFragment(name); } // Private + /** + * @param {import('dictionary').TermHeadword} headword + * @param {number} headwordIndex + * @param {import('dictionary').TermPronunciation[]} pronunciations + * @returns {HTMLElement} + */ _createTermHeadword(headword, headwordIndex, pronunciations) { const {term, reading, tags, sources} = headword; @@ -276,9 +329,9 @@ export class DisplayGenerator { matchSources.add(matchSource); } - const node = this._templates.instantiate('headword'); + const node = this._instantiate('headword'); - const termContainer = node.querySelector('.headword-term'); + const termContainer = this._querySelector(node, '.headword-term'); node.dataset.isPrimary = `${isPrimaryAny}`; node.dataset.readingIsSame = `${reading === term}`; @@ -295,30 +348,43 @@ export class DisplayGenerator { node.dataset.wordClasses = wordClasses.join(' '); } - this._setTextContent(node.querySelector('.headword-reading'), reading); + const headwordReading = this._querySelector(node, '.headword-reading'); + this._setTextContent(headwordReading, reading); this._appendFurigana(termContainer, term, reading, this._appendKanjiLinks.bind(this)); return node; } + /** + * @param {string} inflection + * @returns {DocumentFragment} + */ _createTermInflection(inflection) { const fragment = this._templates.instantiateFragment('inflection'); - const node = fragment.querySelector('.inflection'); + const node = this._querySelector(fragment, '.inflection'); this._setTextContent(node, inflection); node.dataset.reason = inflection; return fragment; } + /** + * @param {import('dictionary').TermDefinition} definition + * @param {import('dictionary').Tag} dictionaryTag + * @param {import('dictionary').TermHeadword[]} headwords + * @param {Set<string>} uniqueTerms + * @param {Set<string>} uniqueReadings + * @returns {HTMLElement} + */ _createTermDefinition(definition, dictionaryTag, headwords, uniqueTerms, uniqueReadings) { const {dictionary, tags, headwordIndices, entries} = definition; const disambiguations = DictionaryDataUtil.getDisambiguations(headwords, headwordIndices, uniqueTerms, uniqueReadings); - const node = this._templates.instantiate('definition-item'); + const node = this._instantiate('definition-item'); - const tagListContainer = node.querySelector('.definition-tag-list'); - const onlyListContainer = node.querySelector('.definition-disambiguation-list'); - const entriesContainer = node.querySelector('.gloss-list'); + const tagListContainer = this._querySelector(node, '.definition-tag-list'); + const onlyListContainer = this._querySelector(node, '.definition-disambiguation-list'); + const entriesContainer = this._querySelector(node, '.gloss-list'); node.dataset.dictionary = dictionary; @@ -329,6 +395,11 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary-data').TermGlossary} entry + * @param {string} dictionary + * @returns {?HTMLElement} + */ _createTermDefinitionEntry(entry, dictionary) { if (typeof entry === 'string') { return this._createTermDefinitionEntryText(entry); @@ -344,25 +415,34 @@ export class DisplayGenerator { return null; } + /** + * @param {string} text + * @returns {HTMLElement} + */ _createTermDefinitionEntryText(text) { - const node = this._templates.instantiate('gloss-item'); - const container = node.querySelector('.gloss-content'); + const node = this._instantiate('gloss-item'); + const container = this._querySelector(node, '.gloss-content'); this._setMultilineTextContent(container, text); return node; } + /** + * @param {import('dictionary-data').TermGlossaryImage} data + * @param {string} dictionary + * @returns {HTMLElement} + */ _createTermDefinitionEntryImage(data, dictionary) { const {description} = data; - const node = this._templates.instantiate('gloss-item'); + const node = this._instantiate('gloss-item'); - const contentContainer = node.querySelector('.gloss-content'); + const contentContainer = this._querySelector(node, '.gloss-content'); const image = this._structuredContentGenerator.createDefinitionImage(data, dictionary); contentContainer.appendChild(image); if (typeof description === 'string') { const fragment = this._templates.instantiateFragment('gloss-item-image-description'); - const container = fragment.querySelector('.gloss-image-description'); + const container = this._querySelector(fragment, '.gloss-image-description'); this._setMultilineTextContent(container, description); contentContainer.appendChild(fragment); } @@ -370,20 +450,33 @@ export class DisplayGenerator { return node; } + /** + * @param {import('structured-content').Content} content + * @param {string} dictionary + * @returns {HTMLElement} + */ _createTermDefinitionEntryStructuredContent(content, dictionary) { - const node = this._templates.instantiate('gloss-item'); - const contentContainer = node.querySelector('.gloss-content'); + const node = this._instantiate('gloss-item'); + const contentContainer = this._querySelector(node, '.gloss-content'); this._structuredContentGenerator.appendStructuredContent(contentContainer, content, dictionary); return node; } + /** + * @param {string} disambiguation + * @returns {HTMLElement} + */ _createTermDisambiguation(disambiguation) { - const node = this._templates.instantiate('definition-disambiguation'); + const node = this._instantiate('definition-disambiguation'); node.dataset.term = disambiguation; this._setTextContent(node, disambiguation, 'ja'); return node; } + /** + * @param {string} character + * @returns {HTMLAnchorElement} + */ _createKanjiLink(character) { const node = document.createElement('a'); node.className = 'headword-kanji-link'; @@ -391,22 +484,34 @@ export class DisplayGenerator { return node; } + /** + * @param {string} text + * @returns {HTMLElement} + */ _createKanjiDefinition(text) { - const node = this._templates.instantiate('kanji-gloss-item'); - const container = node.querySelector('.kanji-gloss-content'); + const node = this._instantiate('kanji-gloss-item'); + const container = this._querySelector(node, '.kanji-gloss-content'); this._setMultilineTextContent(container, text); return node; } + /** + * @param {string} reading + * @returns {HTMLElement} + */ _createKanjiReading(reading) { - const node = this._templates.instantiate('kanji-reading'); + const node = this._instantiate('kanji-reading'); this._setTextContent(node, reading, 'ja'); return node; } + /** + * @param {import('dictionary').KanjiStat[]} details + * @returns {HTMLElement} + */ _createKanjiInfoTable(details) { - const node = this._templates.instantiate('kanji-info-table'); - const container = node.querySelector('.kanji-info-table-body'); + const node = this._instantiate('kanji-info-table'); + const container = this._querySelector(node, '.kanji-info-table-body'); const count = this._appendMultiple(container, this._createKanjiInfoTableItem.bind(this), details); if (count === 0) { @@ -417,25 +522,36 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary').KanjiStat} details + * @returns {HTMLElement} + */ _createKanjiInfoTableItem(details) { const {content, name, value} = details; - const node = this._templates.instantiate('kanji-info-table-item'); - const nameNode = node.querySelector('.kanji-info-table-item-header'); - const valueNode = node.querySelector('.kanji-info-table-item-value'); + const node = this._instantiate('kanji-info-table-item'); + const nameNode = this._querySelector(node, '.kanji-info-table-item-header'); + const valueNode = this._querySelector(node, '.kanji-info-table-item-value'); this._setTextContent(nameNode, content.length > 0 ? content : name); - this._setTextContent(valueNode, value); + this._setTextContent(valueNode, typeof value === 'string' ? value : `${value}`); return node; } + /** + * @returns {HTMLElement} + */ _createKanjiInfoTableItemEmpty() { - return this._templates.instantiate('kanji-info-table-empty'); + return this._instantiate('kanji-info-table-empty'); } + /** + * @param {import('dictionary').Tag} tag + * @returns {HTMLElement} + */ _createTag(tag) { const {content, name, category, redundant} = tag; - const node = this._templates.instantiate('tag'); + const node = this._instantiate('tag'); - const inner = node.querySelector('.tag-label-content'); + const inner = this._querySelector(node, '.tag-label-content'); const contentString = content.join('\n'); @@ -448,6 +564,11 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary-data-util').TagGroup} tagInfo + * @param {number} totalHeadwordCount + * @returns {HTMLElement} + */ _createTermTag(tagInfo, totalHeadwordCount) { const {tag, headwordIndices} = tagInfo; const node = this._createTag(tag); @@ -458,6 +579,11 @@ export class DisplayGenerator { return node; } + /** + * @param {string} name + * @param {string} category + * @returns {import('dictionary').Tag} + */ _createTagData(name, category) { return { name, @@ -470,20 +596,29 @@ export class DisplayGenerator { }; } + /** + * @param {string} text + * @returns {HTMLElement} + */ _createSearchTag(text) { return this._createTag(this._createTagData(text, 'search')); } + /** + * @param {import('dictionary-data-util').DictionaryGroupedPronunciations} details + * @returns {HTMLElement} + */ _createGroupedPronunciation(details) { const {dictionary, pronunciations} = details; - const node = this._templates.instantiate('pronunciation-group'); + const node = this._instantiate('pronunciation-group'); node.dataset.dictionary = dictionary; node.dataset.pronunciationsMulti = 'true'; node.dataset.pronunciationsCount = `${pronunciations.length}`; + const n1 = this._querySelector(node, '.pronunciation-group-tag-list'); const tag = this._createTag(this._createTagData(dictionary, 'pronunciation-dictionary')); - node.querySelector('.pronunciation-group-tag-list').appendChild(tag); + n1.appendChild(tag); let hasTags = false; for (const {tags} of pronunciations) { @@ -493,54 +628,64 @@ export class DisplayGenerator { } } - const n = node.querySelector('.pronunciation-list'); + const n = this._querySelector(node, '.pronunciation-list'); n.dataset.hasTags = `${hasTags}`; this._appendMultiple(n, this._createPronunciation.bind(this), pronunciations); return node; } + /** + * @param {import('dictionary-data-util').GroupedPronunciation} details + * @returns {HTMLElement} + */ _createPronunciation(details) { const jp = this._japaneseUtil; const {reading, position, nasalPositions, devoicePositions, tags, exclusiveTerms, exclusiveReadings} = details; const morae = jp.getKanaMorae(reading); - const node = this._templates.instantiate('pronunciation'); + const node = this._instantiate('pronunciation'); node.dataset.pitchAccentDownstepPosition = `${position}`; if (nasalPositions.length > 0) { node.dataset.nasalMoraPosition = nasalPositions.join(' '); } if (devoicePositions.length > 0) { node.dataset.devoiceMoraPosition = devoicePositions.join(' '); } node.dataset.tagCount = `${tags.length}`; - let n = node.querySelector('.pronunciation-tag-list'); + let n = this._querySelector(node, '.pronunciation-tag-list'); this._appendMultiple(n, this._createTag.bind(this), tags); - n = node.querySelector('.pronunciation-disambiguation-list'); + n = this._querySelector(node, '.pronunciation-disambiguation-list'); this._createPronunciationDisambiguations(n, exclusiveTerms, exclusiveReadings); - n = node.querySelector('.pronunciation-downstep-notation-container'); + n = this._querySelector(node, '.pronunciation-downstep-notation-container'); n.appendChild(this._pronunciationGenerator.createPronunciationDownstepPosition(position)); - n = node.querySelector('.pronunciation-text-container'); + n = this._querySelector(node, '.pronunciation-text-container'); n.lang = 'ja'; n.appendChild(this._pronunciationGenerator.createPronunciationText(morae, position, nasalPositions, devoicePositions)); - node.querySelector('.pronunciation-graph-container').appendChild(this._pronunciationGenerator.createPronunciationGraph(morae, position)); + n = this._querySelector(node, '.pronunciation-graph-container'); + n.appendChild(this._pronunciationGenerator.createPronunciationGraph(morae, position)); return node; } + /** + * @param {HTMLElement} container + * @param {string[]} exclusiveTerms + * @param {string[]} exclusiveReadings + */ _createPronunciationDisambiguations(container, exclusiveTerms, exclusiveReadings) { const templateName = 'pronunciation-disambiguation'; for (const term of exclusiveTerms) { - const node = this._templates.instantiate(templateName); + const node = this._instantiate(templateName); node.dataset.type = 'term'; this._setTextContent(node, term, 'ja'); container.appendChild(node); } for (const exclusiveReading of exclusiveReadings) { - const node = this._templates.instantiate(templateName); + const node = this._instantiate(templateName); node.dataset.type = 'reading'; this._setTextContent(node, exclusiveReading, 'ja'); container.appendChild(node); @@ -551,19 +696,29 @@ export class DisplayGenerator { container.dataset.readingCount = `${exclusiveReadings.length}`; } + /** + * @param {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').TermFrequency>|import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').KanjiFrequency>} details + * @param {boolean} kanji + * @returns {HTMLElement} + */ _createFrequencyGroup(details, kanji) { const {dictionary, frequencies} = details; - const node = this._templates.instantiate('frequency-group-item'); - const body = node.querySelector('.tag-body-content'); + const node = this._instantiate('frequency-group-item'); + const body = this._querySelector(node, '.tag-body-content'); - this._setTextContent(node.querySelector('.tag-label-content'), dictionary); + const tagLabel = this._querySelector(node, '.tag-label-content'); + this._setTextContent(tagLabel, dictionary); node.dataset.details = dictionary; const ii = frequencies.length; for (let i = 0; i < ii; ++i) { const item = frequencies[i]; - const itemNode = (kanji ? this._createKanjiFrequency(item, dictionary) : this._createTermFrequency(item, dictionary)); + const itemNode = ( + kanji ? + this._createKanjiFrequency(/** @type {import('dictionary-data-util').KanjiFrequency} */ (item), dictionary) : + this._createTermFrequency(/** @type {import('dictionary-data-util').TermFrequency} */ (item), dictionary) + ); itemNode.dataset.index = `${i}`; body.appendChild(itemNode); } @@ -575,18 +730,28 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary-data-util').TermFrequency} details + * @param {string} dictionary + * @returns {HTMLElement} + */ _createTermFrequency(details, dictionary) { const {term, reading, values} = details; - const node = this._templates.instantiate('term-frequency-item'); + const node = this._instantiate('term-frequency-item'); + const tagLabel = this._querySelector(node, '.tag-label-content'); + const disambiguationTerm = this._querySelector(node, '.frequency-disambiguation-term'); + const disambiguationReading = this._querySelector(node, '.frequency-disambiguation-reading'); + const frequencyValueList = this._querySelector(node, '.frequency-value-list'); - this._setTextContent(node.querySelector('.tag-label-content'), dictionary); - - this._setTextContent(node.querySelector('.frequency-disambiguation-term'), term, 'ja'); - this._setTextContent(node.querySelector('.frequency-disambiguation-reading'), (reading !== null ? reading : ''), 'ja'); - this._populateFrequencyValueList(node.querySelector('.frequency-value-list'), values); + this._setTextContent(tagLabel, dictionary); + this._setTextContent(disambiguationTerm, term, 'ja'); + this._setTextContent(disambiguationReading, (reading !== null ? reading : ''), 'ja'); + this._populateFrequencyValueList(frequencyValueList, values); node.dataset.term = term; - node.dataset.reading = reading; + if (typeof reading === 'string') { + node.dataset.reading = reading; + } node.dataset.hasReading = `${reading !== null}`; node.dataset.readingIsSame = `${reading === term}`; node.dataset.dictionary = dictionary; @@ -595,12 +760,19 @@ export class DisplayGenerator { return node; } + /** + * @param {import('dictionary-data-util').KanjiFrequency} details + * @param {string} dictionary + * @returns {HTMLElement} + */ _createKanjiFrequency(details, dictionary) { const {character, values} = details; - const node = this._templates.instantiate('kanji-frequency-item'); + const node = this._instantiate('kanji-frequency-item'); + const tagLabel = this._querySelector(node, '.tag-label-content'); + const frequencyValueList = this._querySelector(node, '.frequency-value-list'); - this._setTextContent(node.querySelector('.tag-label-content'), dictionary); - this._populateFrequencyValueList(node.querySelector('.frequency-value-list'), values); + this._setTextContent(tagLabel, dictionary); + this._populateFrequencyValueList(frequencyValueList, values); node.dataset.character = character; node.dataset.dictionary = dictionary; @@ -609,12 +781,16 @@ export class DisplayGenerator { return node; } + /** + * @param {HTMLElement} node + * @param {import('dictionary-data-util').FrequencyData[]} values + */ _populateFrequencyValueList(node, values) { let fullFrequency = ''; for (let i = 0, ii = values.length; i < ii; ++i) { const {frequency, displayValue} = values[i]; const frequencyString = `${frequency}`; - const text = displayValue !== null ? displayValue : frequency; + const text = displayValue !== null ? displayValue : `${frequency}`; if (i > 0) { const node2 = document.createElement('span'); @@ -643,11 +819,15 @@ export class DisplayGenerator { node.dataset.frequency = fullFrequency; } + /** + * @param {HTMLElement} container + * @param {string} text + */ _appendKanjiLinks(container, text) { const jp = this._japaneseUtil; let part = ''; for (const c of text) { - if (jp.isCodePointKanji(c.codePointAt(0))) { + if (jp.isCodePointKanji(/** @type {number} */ (c.codePointAt(0)))) { if (part.length > 0) { container.appendChild(document.createTextNode(part)); part = ''; @@ -664,16 +844,25 @@ export class DisplayGenerator { } } - _appendMultiple(container, createItem, detailsArray, ...args) { + /** + * @template TItem + * @template [TExtraArg=void] + * @param {HTMLElement} container + * @param {(item: TItem, arg: TExtraArg) => ?Node} createItem + * @param {TItem[]} detailsArray + * @param {TExtraArg} [arg] + * @returns {number} + */ + _appendMultiple(container, createItem, detailsArray, arg) { let count = 0; const {ELEMENT_NODE} = Node; if (Array.isArray(detailsArray)) { for (const details of detailsArray) { - const item = createItem(details, ...args); + const item = createItem(details, /** @type {TExtraArg} */ (arg)); if (item === null) { continue; } container.appendChild(item); if (item.nodeType === ELEMENT_NODE) { - item.dataset.index = `${count}`; + /** @type {HTMLElement} */ (item).dataset.index = `${count}`; } ++count; } @@ -684,6 +873,12 @@ export class DisplayGenerator { return count; } + /** + * @param {HTMLElement} container + * @param {string} term + * @param {string} reading + * @param {(element: HTMLElement, text: string) => void} addText + */ _appendFurigana(container, term, reading, addText) { container.lang = 'ja'; const segments = this._japaneseUtil.distributeFurigana(term, reading); @@ -701,10 +896,19 @@ export class DisplayGenerator { } } + /** + * @param {string} dictionary + * @returns {import('dictionary').Tag} + */ _createDictionaryTag(dictionary) { return this._createTagData(dictionary, 'dictionary'); } + /** + * @param {HTMLElement} node + * @param {string} value + * @param {string} [language] + */ _setTextContent(node, value, language) { if (typeof language === 'string') { node.lang = language; @@ -715,6 +919,11 @@ export class DisplayGenerator { node.textContent = value; } + /** + * @param {HTMLElement} node + * @param {string} value + * @param {string} [language] + */ _setMultilineTextContent(node, value, language) { // This can't just call _setTextContent because the lack of <br> elements will // cause the text to not copy correctly. @@ -738,9 +947,17 @@ export class DisplayGenerator { } } + /** + * @param {string} reading + * @param {import('dictionary').TermPronunciation[]} pronunciations + * @param {string[]} wordClasses + * @param {number} headwordIndex + * @returns {?string} + */ _getPronunciationCategories(reading, pronunciations, wordClasses, headwordIndex) { if (pronunciations.length === 0) { return null; } const isVerbOrAdjective = DictionaryDataUtil.isNonNounVerbOrAdjective(wordClasses); + /** @type {Set<import('japanese-util').PitchCategory>} */ const categories = new Set(); for (const pronunciation of pronunciations) { if (pronunciation.headwordIndex !== headwordIndex) { continue; } @@ -753,4 +970,23 @@ export class DisplayGenerator { } return categories.size > 0 ? [...categories].join(' ') : null; } + + /** + * @template {HTMLElement} T + * @param {string} name + * @returns {T} + */ + _instantiate(name) { + return /** @type {T} */ (this._templates.instantiate(name)); + } + + /** + * @template {HTMLElement} T + * @param {Element|DocumentFragment} element + * @param {string} selector + * @returns {T} + */ + _querySelector(element, selector) { + return /** @type {T} */ (element.querySelector(selector)); + } } diff --git a/ext/js/display/display-history.js b/ext/js/display/display-history.js index a983346c..f9d2e35d 100644 --- a/ext/js/display/display-history.js +++ b/ext/js/display/display-history.js @@ -18,26 +18,39 @@ import {EventDispatcher, generateId, isObject} from '../core.js'; +/** + * @augments EventDispatcher<import('display-history').EventType> + */ export class DisplayHistory extends EventDispatcher { + /** + * @param {{clearable?: boolean, useBrowserHistory?: boolean}} details + */ constructor({clearable=true, useBrowserHistory=false}) { super(); + /** @type {boolean} */ this._clearable = clearable; + /** @type {boolean} */ this._useBrowserHistory = useBrowserHistory; + /** @type {Map<string, import('display-history').Entry>} */ this._historyMap = new Map(); const historyState = history.state; const {id, state} = isObject(historyState) ? historyState : {id: null, state: null}; + /** @type {import('display-history').Entry} */ this._current = this._createHistoryEntry(id, location.href, state, null, null); } + /** @type {?import('display-history').EntryState} */ get state() { return this._current.state; } + /** @type {?import('display-history').EntryContent} */ get content() { return this._current.content; } + /** @type {boolean} */ get useBrowserHistory() { return this._useBrowserHistory; } @@ -46,31 +59,54 @@ export class DisplayHistory extends EventDispatcher { this._useBrowserHistory = value; } + /** @type {boolean} */ + get clearable() { return this._clearable; } + set clearable(value) { this._clearable = value; } + + /** */ prepare() { window.addEventListener('popstate', this._onPopState.bind(this), false); } + /** + * @returns {boolean} + */ hasNext() { return this._current.next !== null; } + /** + * @returns {boolean} + */ hasPrevious() { return this._current.previous !== null; } + /** */ clear() { if (!this._clearable) { return; } this._clear(); } + /** + * @returns {boolean} + */ back() { return this._go(false); } + /** + * @returns {boolean} + */ forward() { return this._go(true); } + /** + * @param {?import('display-history').EntryState} state + * @param {?import('display-history').EntryContent} content + * @param {string} [url] + */ pushState(state, content, url) { if (typeof url === 'undefined') { url = location.href; } @@ -80,6 +116,11 @@ export class DisplayHistory extends EventDispatcher { this._updateHistoryFromCurrent(!this._useBrowserHistory); } + /** + * @param {?import('display-history').EntryState} state + * @param {?import('display-history').EntryContent} content + * @param {string} [url] + */ replaceState(state, content, url) { if (typeof url === 'undefined') { url = location.href; } @@ -89,11 +130,16 @@ export class DisplayHistory extends EventDispatcher { this._updateHistoryFromCurrent(true); } + /** */ _onPopState() { this._updateStateFromHistory(); this._triggerStateChanged(false); } + /** + * @param {boolean} forward + * @returns {boolean} + */ _go(forward) { if (this._useBrowserHistory) { if (forward) { @@ -111,10 +157,16 @@ export class DisplayHistory extends EventDispatcher { return true; } + /** + * @param {boolean} synthetic + */ _triggerStateChanged(synthetic) { - this.trigger('stateChanged', {synthetic}); + this.trigger('stateChanged', /** @type {import('display-history').StateChangedEvent} */ ({synthetic})); } + /** + * @param {boolean} replace + */ _updateHistoryFromCurrent(replace) { const {id, state, url} = this._current; if (replace) { @@ -125,6 +177,7 @@ export class DisplayHistory extends EventDispatcher { this._triggerStateChanged(true); } + /** */ _updateStateFromHistory() { let state = history.state; let id = null; @@ -151,24 +204,36 @@ export class DisplayHistory extends EventDispatcher { this._clear(); } + /** + * @param {unknown} id + * @param {string} url + * @param {?import('display-history').EntryState} state + * @param {?import('display-history').EntryContent} content + * @param {?import('display-history').Entry} previous + * @returns {import('display-history').Entry} + */ _createHistoryEntry(id, url, state, content, previous) { - if (typeof id !== 'string') { id = this._generateId(); } + /** @type {import('display-history').Entry} */ const entry = { - id, + id: typeof id === 'string' ? id : this._generateId(), url, next: null, previous, state, content }; - this._historyMap.set(id, entry); + this._historyMap.set(entry.id, entry); return entry; } + /** + * @returns {string} + */ _generateId() { return generateId(16); } + /** */ _clear() { this._historyMap.clear(); this._historyMap.set(this._current.id, this._current); diff --git a/ext/js/display/display-notification.js b/ext/js/display/display-notification.js index 0c26c613..d1cfa537 100644 --- a/ext/js/display/display-notification.js +++ b/ext/js/display/display-notification.js @@ -19,23 +19,36 @@ import {EventListenerCollection} from '../core.js'; export class DisplayNotification { + /** + * @param {HTMLElement} container + * @param {HTMLElement} node + */ constructor(container, node) { + /** @type {HTMLElement} */ this._container = container; + /** @type {HTMLElement} */ this._node = node; - this._body = node.querySelector('.footer-notification-body'); - this._closeButton = node.querySelector('.footer-notification-close-button'); + /** @type {HTMLElement} */ + this._body = /** @type {HTMLElement} */ (node.querySelector('.footer-notification-body')); + /** @type {HTMLElement} */ + this._closeButton = /** @type {HTMLElement} */ (node.querySelector('.footer-notification-close-button')); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?import('core').Timeout} */ this._closeTimer = null; } + /** @type {HTMLElement} */ get container() { return this._container; } + /** @type {HTMLElement} */ get node() { return this._node; } + /** */ open() { if (!this.isClosed()) { return; } @@ -50,6 +63,9 @@ export class DisplayNotification { this._eventListeners.addEventListener(this._closeButton, 'click', this._onCloseButtonClick.bind(this), false); } + /** + * @param {boolean} [animate] + */ close(animate=false) { if (this.isClosed()) { return; } @@ -69,6 +85,9 @@ export class DisplayNotification { } } + /** + * @param {string|Node} value + */ setContent(value) { if (typeof value === 'string') { this._body.textContent = value; @@ -78,25 +97,34 @@ export class DisplayNotification { } } + /** + * @returns {boolean} + */ isClosing() { return this._closeTimer !== null; } + /** + * @returns {boolean} + */ isClosed() { return this._node.parentNode === null; } // Private + /** */ _onCloseButtonClick() { this.close(true); } + /** */ _onDelayClose() { this._closeTimer = null; this.close(false); } + /** */ _clearTimer() { if (this._closeTimer !== null) { clearTimeout(this._closeTimer); diff --git a/ext/js/display/display-profile-selection.js b/ext/js/display/display-profile-selection.js index d8b7185c..c5cb7d06 100644 --- a/ext/js/display/display-profile-selection.js +++ b/ext/js/display/display-profile-selection.js @@ -21,19 +21,30 @@ import {PanelElement} from '../dom/panel-element.js'; import {yomitan} from '../yomitan.js'; export class DisplayProfileSelection { + /** + * @param {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; - this._profielList = document.querySelector('#profile-list'); - this._profileButton = document.querySelector('#profile-button'); + /** @type {HTMLElement} */ + this._profielList = /** @type {HTMLElement} */ (document.querySelector('#profile-list')); + /** @type {HTMLButtonElement} */ + this._profileButton = /** @type {HTMLButtonElement} */ (document.querySelector('#profile-button')); + /** @type {PanelElement} */ this._profilePanel = new PanelElement({ - node: document.querySelector('#profile-panel'), + node: /** @type {HTMLElement} */ (document.querySelector('#profile-panel')), closingAnimationDuration: 375 // Milliseconds; includes buffer }); + /** @type {boolean} */ this._profileListNeedsUpdate = false; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {string} */ this._source = generateId(16); } + /** */ async prepare() { yomitan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); this._profileButton.addEventListener('click', this._onProfileButtonClick.bind(this), false); @@ -42,6 +53,9 @@ export class DisplayProfileSelection { // Private + /** + * @param {{source: string}} details + */ _onOptionsUpdated({source}) { if (source === this._source) { return; } this._profileListNeedsUpdate = true; @@ -50,12 +64,18 @@ export class DisplayProfileSelection { } } + /** + * @param {MouseEvent} e + */ _onProfileButtonClick(e) { e.preventDefault(); e.stopPropagation(); this._setProfilePanelVisible(!this._profilePanel.isVisible()); } + /** + * @param {boolean} visible + */ _setProfilePanelVisible(visible) { this._profilePanel.setVisible(visible); this._profileButton.classList.toggle('sidebar-button-highlight', visible); @@ -65,6 +85,7 @@ export class DisplayProfileSelection { } } + /** */ async _updateProfileList() { this._profileListNeedsUpdate = false; const options = await yomitan.api.optionsGetFull(); @@ -77,9 +98,9 @@ export class DisplayProfileSelection { for (let i = 0, ii = profiles.length; i < ii; ++i) { const {name} = profiles[i]; const entry = displayGenerator.createProfileListItem(); - const radio = entry.querySelector('.profile-entry-is-default-radio'); + const radio = /** @type {HTMLInputElement} */ (entry.querySelector('.profile-entry-is-default-radio')); radio.checked = (i === profileCurrent); - const nameNode = entry.querySelector('.profile-list-item-name'); + const nameNode = /** @type {Element} */ (entry.querySelector('.profile-list-item-name')); nameNode.textContent = name; fragment.appendChild(entry); this._eventListeners.addEventListener(radio, 'change', this._onProfileRadioChange.bind(this, i), false); @@ -88,19 +109,30 @@ export class DisplayProfileSelection { this._profielList.appendChild(fragment); } + /** + * @param {number} index + * @param {Event} e + */ _onProfileRadioChange(index, e) { - if (e.currentTarget.checked) { + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + if (element.checked) { this._setProfileCurrent(index); } } + /** + * @param {number} index + */ async _setProfileCurrent(index) { - await yomitan.api.modifySettings([{ + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'profileCurrent', value: index, - scope: 'global' - }], this._source); + scope: 'global', + optionsContext: null + }; + await yomitan.api.modifySettings([modification], this._source); this._setProfilePanelVisible(false); } } diff --git a/ext/js/display/display-resizer.js b/ext/js/display/display-resizer.js index 2925561f..8ce7e91a 100644 --- a/ext/js/display/display-resizer.js +++ b/ext/js/display/display-resizer.js @@ -19,16 +19,27 @@ import {EventListenerCollection} from '../core.js'; export class DisplayResizer { + /** + * @param {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {?import('core').TokenObject} */ this._token = null; + /** @type {?HTMLElement} */ this._handle = null; + /** @type {?number} */ this._touchIdentifier = null; + /** @type {?{width: number, height: number}} */ this._startSize = null; + /** @type {?{x: number, y: number}} */ this._startOffset = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** */ prepare() { this._handle = document.querySelector('#frame-resizer-handle'); if (this._handle === null) { return; } @@ -39,6 +50,9 @@ export class DisplayResizer { // Private + /** + * @param {MouseEvent} e + */ _onFrameResizerMouseDown(e) { if (e.button !== 0) { return; } // Don't do e.preventDefault() here; this allows mousemove events to be processed @@ -46,19 +60,27 @@ export class DisplayResizer { this._startFrameResize(e); } + /** + * @param {TouchEvent} e + */ _onFrameResizerTouchStart(e) { e.preventDefault(); this._startFrameResizeTouch(e); } + /** */ _onFrameResizerMouseUp() { this._stopFrameResize(); } + /** */ _onFrameResizerWindowBlur() { this._stopFrameResize(); } + /** + * @param {MouseEvent} e + */ _onFrameResizerMouseMove(e) { if ((e.buttons & 0x1) === 0x0) { this._stopFrameResize(); @@ -69,16 +91,25 @@ export class DisplayResizer { } } + /** + * @param {TouchEvent} e + */ _onFrameResizerTouchEnd(e) { if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; } this._stopFrameResize(); } + /** + * @param {TouchEvent} e + */ _onFrameResizerTouchCancel(e) { if (this._getTouch(e.changedTouches, this._touchIdentifier) === null) { return; } this._stopFrameResize(); } + /** + * @param {TouchEvent} e + */ _onFrameResizerTouchMove(e) { if (this._startSize === null) { return; } const primaryTouch = this._getTouch(e.changedTouches, this._touchIdentifier); @@ -87,10 +118,14 @@ export class DisplayResizer { this._updateFrameSize(x, y); } + /** + * @param {MouseEvent} e + */ _startFrameResize(e) { if (this._token !== null) { return; } const {clientX: x, clientY: y} = e; + /** @type {?import('core').TokenObject} */ const token = {}; this._token = token; this._startOffset = {x, y}; @@ -106,10 +141,14 @@ export class DisplayResizer { this._initializeFrameResize(token); } + /** + * @param {TouchEvent} e + */ _startFrameResizeTouch(e) { if (this._token !== null) { return; } const {clientX: x, clientY: y, identifier} = e.changedTouches[0]; + /** @type {?import('core').TokenObject} */ const token = {}; this._token = token; this._startOffset = {x, y}; @@ -127,15 +166,21 @@ export class DisplayResizer { this._initializeFrameResize(token); } + /** + * @param {import('core').TokenObject} token + */ async _initializeFrameResize(token) { const {parentPopupId} = this._display; if (parentPopupId === null) { return; } + /** @type {import('popup').ValidSize} */ const size = await this._display.invokeParentFrame('PopupFactory.getFrameSize', {id: parentPopupId}); if (this._token !== token) { return; } - this._startSize = size; + const {width, height} = size; + this._startSize = {width, height}; } + /** */ _stopFrameResize() { if (this._token === null) { return; } @@ -151,9 +196,13 @@ export class DisplayResizer { } } + /** + * @param {number} x + * @param {number} y + */ async _updateFrameSize(x, y) { const {parentPopupId} = this._display; - if (parentPopupId === null) { return; } + if (parentPopupId === null || this._handle === null || this._startOffset === null || this._startSize === null) { return; } const handleSize = this._handle.getBoundingClientRect(); let {width, height} = this._startSize; @@ -164,6 +213,11 @@ export class DisplayResizer { await this._display.invokeParentFrame('PopupFactory.setFrameSize', {id: parentPopupId, width, height}); } + /** + * @param {TouchList} touchList + * @param {?number} identifier + * @returns {?Touch} + */ _getTouch(touchList, identifier) { for (const touch of touchList) { if (touch.identifier === identifier) { diff --git a/ext/js/display/display.js b/ext/js/display/display.js index 038a76bb..6e1450c3 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -20,7 +20,7 @@ import {Frontend} from '../app/frontend.js'; import {PopupFactory} from '../app/popup-factory.js'; import {ThemeController} from '../app/theme-controller.js'; import {FrameEndpoint} from '../comm/frame-endpoint.js'; -import {DynamicProperty, EventDispatcher, EventListenerCollection, clone, deepEqual, invokeMessageHandler, isObject, log, promiseTimeout} from '../core.js'; +import {DynamicProperty, EventDispatcher, EventListenerCollection, clone, deepEqual, invokeMessageHandler, log, promiseTimeout} from '../core.js'; import {PopupMenu} from '../dom/popup-menu.js'; import {ScrollElement} from '../dom/scroll-element.js'; import {HotkeyHelpController} from '../input/hotkey-help-controller.js'; @@ -35,132 +35,164 @@ import {ElementOverflowController} from './element-overflow-controller.js'; import {OptionToggleHotkeyHandler} from './option-toggle-hotkey-handler.js'; import {QueryParser} from './query-parser.js'; +/** + * @augments EventDispatcher<import('display').DisplayEventType> + */ export class Display extends EventDispatcher { /** - * Information about how popup content should be shown, specifically related to the inner popup content. - * @typedef {object} ContentDetails - * @property {boolean} focus Whether or not the frame should be `focus()`'d. - * @property {HistoryParams} params An object containing key-value pairs representing the URL search params. - * @property {?HistoryState} state The semi-persistent state assigned to the navigation entry. - * @property {?HistoryContent} content The non-persistent content assigned to the navigation entry. - * @property {'clear'|'overwrite'|'new'} historyMode How the navigation history should be modified. - */ - - /** - * An object containing key-value pairs representing the URL search params. - * @typedef {object} HistoryParams - * @property {'terms'|'kanji'|'unloaded'|'clear'} [type] The type of content that is being shown. - * @property {string} [query] The search query. - * @property {'on'|'off'} [wildcards] Whether or not wildcards can be used for the search query. - * @property {string} [offset] The start position of the `query` string as an index into the `full` query string. - * @property {string} [full] The full search text. If absent, `query` is the full search text. - * @property {'true'|'false'} [full-visible] Whether or not the full search query should be forced to be visible. - * @property {'true'|'false'} [lookup] Whether or not the query should be looked up. If it is not looked up, - * the content should be provided. - */ - - /** - * The semi-persistent state assigned to the navigation entry. - * @typedef {object} HistoryState - * @property {'queryParser'} [cause] What was the cause of the navigation. - * @property {object} [sentence] The sentence context. - * @property {string} sentence.text The full string. - * @property {number} sentence.offset The offset from the start of `text` to the full search query. - * @property {number} [focusEntry] The index of the dictionary entry to focus. - * @property {number} [scrollX] The horizontal scroll position. - * @property {number} [scrollY] The vertical scroll position. - * @property {object} [optionsContext] The options context which should be used for lookups. - * @property {string} [url] The originating URL of the content. - * @property {string} [documentTitle] The originating document title of the content. - */ - - /** - * The non-persistent content assigned to the navigation entry. - * @typedef {object} HistoryContent - * @property {boolean} [animate] Whether or not any CSS animations should occur. - * @property {object[]} [dictionaryEntries] An array of dictionary entries to display as content. - * @property {object} [contentOrigin] The identifying information for the frame the content originated from. - * @property {number} [contentOrigin.tabId] The tab id. - * @property {number} [contentOrigin.frameId] The frame id within the tab. + * @param {number|undefined} tabId + * @param {number|undefined} frameId + * @param {import('display').DisplayPageType} pageType + * @param {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {import('../dom/document-focus-controller.js').DocumentFocusController} documentFocusController + * @param {import('../input/hotkey-handler.js').HotkeyHandler} hotkeyHandler */ - constructor(tabId, frameId, pageType, japaneseUtil, documentFocusController, hotkeyHandler) { super(); + /** @type {number|undefined} */ this._tabId = tabId; + /** @type {number|undefined} */ this._frameId = frameId; + /** @type {import('display').DisplayPageType} */ this._pageType = pageType; + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('../dom/document-focus-controller.js').DocumentFocusController} */ this._documentFocusController = documentFocusController; + /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */ this._hotkeyHandler = hotkeyHandler; - this._container = document.querySelector('#dictionary-entries'); + /** @type {HTMLElement} */ + this._container = /** @type {HTMLElement} */ (document.querySelector('#dictionary-entries')); + /** @type {import('dictionary').DictionaryEntry[]} */ this._dictionaryEntries = []; + /** @type {HTMLElement[]} */ this._dictionaryEntryNodes = []; + /** @type {import('settings').OptionsContext} */ this._optionsContext = {depth: 0, url: window.location.href}; + /** @type {?import('settings').ProfileOptions} */ this._options = null; + /** @type {number} */ this._index = 0; + /** @type {?HTMLStyleElement} */ this._styleNode = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?import('core').TokenObject} */ this._setContentToken = null; + /** @type {DisplayContentManager} */ this._contentManager = new DisplayContentManager(this); + /** @type {HotkeyHelpController} */ this._hotkeyHelpController = new HotkeyHelpController(); + /** @type {DisplayGenerator} */ this._displayGenerator = new DisplayGenerator({ japaneseUtil, contentManager: this._contentManager, hotkeyHelpController: this._hotkeyHelpController }); + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = new Map(); + /** @type {import('core').MessageHandlerMap} */ this._directMessageHandlers = new Map(); + /** @type {import('core').MessageHandlerMap} */ this._windowMessageHandlers = new Map(); + /** @type {DisplayHistory} */ this._history = new DisplayHistory({clearable: true, useBrowserHistory: false}); + /** @type {boolean} */ this._historyChangeIgnore = false; + /** @type {boolean} */ this._historyHasChanged = false; + /** @type {?Element} */ this._navigationHeader = document.querySelector('#navigation-header'); + /** @type {import('display').PageType} */ this._contentType = 'clear'; + /** @type {string} */ this._defaultTitle = document.title; + /** @type {number} */ this._titleMaxLength = 1000; + /** @type {string} */ this._query = ''; + /** @type {string} */ this._fullQuery = ''; + /** @type {number} */ this._queryOffset = 0; - this._progressIndicator = document.querySelector('#progress-indicator'); + /** @type {HTMLElement} */ + this._progressIndicator = /** @type {HTMLElement} */ (document.querySelector('#progress-indicator')); + /** @type {?import('core').Timeout} */ this._progressIndicatorTimer = null; + /** @type {DynamicProperty<boolean>} */ this._progressIndicatorVisible = new DynamicProperty(false); + /** @type {boolean} */ this._queryParserVisible = false; + /** @type {?boolean} */ this._queryParserVisibleOverride = null; - this._queryParserContainer = document.querySelector('#query-parser-container'); + /** @type {HTMLElement} */ + this._queryParserContainer = /** @type {HTMLElement} */ (document.querySelector('#query-parser-container')); + /** @type {QueryParser} */ this._queryParser = new QueryParser({ getSearchContext: this._getSearchContext.bind(this), japaneseUtil }); - this._contentScrollElement = document.querySelector('#content-scroll'); - this._contentScrollBodyElement = document.querySelector('#content-body'); + /** @type {HTMLElement} */ + this._contentScrollElement = /** @type {HTMLElement} */ (document.querySelector('#content-scroll')); + /** @type {HTMLElement} */ + this._contentScrollBodyElement = /** @type {HTMLElement} */ (document.querySelector('#content-body')); + /** @type {ScrollElement} */ this._windowScroll = new ScrollElement(this._contentScrollElement); - this._closeButton = document.querySelector('#close-button'); - this._navigationPreviousButton = document.querySelector('#navigate-previous-button'); - this._navigationNextButton = document.querySelector('#navigate-next-button'); + /** @type {HTMLButtonElement} */ + this._closeButton = /** @type {HTMLButtonElement} */ (document.querySelector('#close-button')); + /** @type {HTMLButtonElement} */ + this._navigationPreviousButton = /** @type {HTMLButtonElement} */ (document.querySelector('#navigate-previous-button')); + /** @type {HTMLButtonElement} */ + this._navigationNextButton = /** @type {HTMLButtonElement} */ (document.querySelector('#navigate-next-button')); + /** @type {?Frontend} */ this._frontend = null; + /** @type {?Promise<void>} */ this._frontendSetupPromise = null; + /** @type {number} */ this._depth = 0; + /** @type {?string} */ this._parentPopupId = null; + /** @type {?number} */ this._parentFrameId = null; + /** @type {number|undefined} */ this._contentOriginTabId = tabId; + /** @type {number|undefined} */ this._contentOriginFrameId = frameId; + /** @type {boolean} */ this._childrenSupported = true; + /** @type {?FrameEndpoint} */ this._frameEndpoint = (pageType === 'popup' ? new FrameEndpoint() : null); + /** @type {?import('environment').Browser} */ this._browser = null; + /** @type {?HTMLTextAreaElement} */ this._copyTextarea = null; + /** @type {?TextScanner} */ this._contentTextScanner = null; + /** @type {?import('./display-notification.js').DisplayNotification} */ this._tagNotification = null; - this._footerNotificationContainer = document.querySelector('#content-footer'); + /** @type {HTMLElement} */ + this._footerNotificationContainer = /** @type {HTMLElement} */ (document.querySelector('#content-footer')); + /** @type {OptionToggleHotkeyHandler} */ this._optionToggleHotkeyHandler = new OptionToggleHotkeyHandler(this); + /** @type {ElementOverflowController} */ this._elementOverflowController = new ElementOverflowController(); + /** @type {boolean} */ this._frameVisible = (pageType === 'search'); - this._menuContainer = document.querySelector('#popup-menus'); + /** @type {HTMLElement} */ + this._menuContainer = /** @type {HTMLElement} */ (document.querySelector('#popup-menus')); + /** @type {(event: MouseEvent) => void} */ this._onEntryClickBind = this._onEntryClick.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onKanjiLookupBind = this._onKanjiLookup.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onDebugLogClickBind = this._onDebugLogClick.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onTagClickBind = this._onTagClick.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this); + /** @type {(event: import('popup-menu').MenuCloseEvent) => void} */ this._onMenuButtonMenuCloseBind = this._onMenuButtonMenuClose.bind(this); + /** @type {ThemeController} */ this._themeController = new ThemeController(document.documentElement); this._hotkeyHandler.registerActions([ @@ -188,10 +220,12 @@ export class Display extends EventDispatcher { ]); } + /** @type {DisplayGenerator} */ get displayGenerator() { return this._displayGenerator; } + /** @type {boolean} */ get queryParserVisible() { return this._queryParserVisible; } @@ -201,58 +235,72 @@ export class Display extends EventDispatcher { this._updateQueryParser(); } + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ get japaneseUtil() { return this._japaneseUtil; } + /** @type {number} */ get depth() { return this._depth; } + /** @type {import('../input/hotkey-handler.js').HotkeyHandler} */ get hotkeyHandler() { return this._hotkeyHandler; } + /** @type {import('dictionary').DictionaryEntry[]} */ get dictionaryEntries() { return this._dictionaryEntries; } + /** @type {HTMLElement[]} */ get dictionaryEntryNodes() { return this._dictionaryEntryNodes; } + /** @type {DynamicProperty<boolean>} */ get progressIndicatorVisible() { return this._progressIndicatorVisible; } + /** @type {?string} */ get parentPopupId() { return this._parentPopupId; } + /** @type {number} */ get selectedIndex() { return this._index; } + /** @type {DisplayHistory} */ get history() { return this._history; } + /** @type {string} */ get query() { return this._query; } + /** @type {string} */ get fullQuery() { return this._fullQuery; } + /** @type {number} */ get queryOffset() { return this._queryOffset; } + /** @type {boolean} */ get frameVisible() { return this._frameVisible; } + /** */ async prepare() { // Theme this._themeController.siteTheme = 'light'; @@ -302,6 +350,9 @@ export class Display extends EventDispatcher { } } + /** + * @returns {import('extension').ContentOrigin} + */ getContentOrigin() { return { tabId: this._contentOriginTabId, @@ -309,6 +360,7 @@ export class Display extends EventDispatcher { }; } + /** */ initializeState() { this._onStateChanged(); if (this._frameEndpoint !== null) { @@ -316,6 +368,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {{clearable?: boolean, useBrowserHistory?: boolean}} details + */ setHistorySettings({clearable, useBrowserHistory}) { if (typeof clearable !== 'undefined') { this._history.clearable = clearable; @@ -325,24 +380,37 @@ export class Display extends EventDispatcher { } } + /** + * @param {Error} error + */ onError(error) { if (yomitan.isExtensionUnloaded) { return; } log.error(error); } + /** + * @returns {?import('settings').ProfileOptions} + */ getOptions() { return this._options; } + /** + * @returns {import('settings').OptionsContext} + */ getOptionsContext() { return this._optionsContext; } + /** + * @param {import('settings').OptionsContext} optionsContext + */ async setOptionsContext(optionsContext) { this._optionsContext = optionsContext; await this.updateOptions(); } + /** */ async updateOptions() { const options = await yomitan.api.optionsGet(this.getOptionsContext()); const {scanning: scanningOptions, sentenceParsing: sentenceParsingOptions} = options; @@ -381,12 +449,14 @@ export class Display extends EventDispatcher { this._updateNestedFrontend(options); this._updateContentTextScanner(options); - this.trigger('optionsUpdated', {options}); + /** @type {import('display').OptionsUpdatedEvent} */ + const event = {options}; + this.trigger('optionsUpdated', event); } /** * Updates the content of the display. - * @param {ContentDetails} details Information about the content to show. + * @param {import('display').ContentDetails} details Information about the content to show. */ setContent(details) { const {focus, params, state, content} = details; @@ -398,6 +468,7 @@ export class Display extends EventDispatcher { const urlSearchParams = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { + if (typeof value !== 'string') { continue; } urlSearchParams.append(key, value); } const url = `${location.protocol}//${location.host}${location.pathname}?${urlSearchParams.toString()}`; @@ -417,6 +488,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {string} css + */ setCustomCss(css) { if (this._styleNode === null) { if (css.length === 0) { return; } @@ -431,18 +505,25 @@ export class Display extends EventDispatcher { } } + /** + * @param {import('core').MessageHandlerArray} handlers + */ registerDirectMessageHandlers(handlers) { for (const [name, handlerInfo] of handlers) { this._directMessageHandlers.set(name, handlerInfo); } } + /** + * @param {import('core').MessageHandlerArray} handlers + */ registerWindowMessageHandlers(handlers) { for (const [name, handlerInfo] of handlers) { this._windowMessageHandlers.set(name, handlerInfo); } } + /** */ close() { switch (this._pageType) { case 'popup': @@ -454,49 +535,72 @@ export class Display extends EventDispatcher { } } + /** + * @param {HTMLElement} element + */ blurElement(element) { this._documentFocusController.blurElement(element); } + /** + * @param {boolean} updateOptionsContext + */ searchLast(updateOptionsContext) { const type = this._contentType; if (type === 'clear') { return; } const query = this._query; - const hasState = this._historyHasState(); - const state = ( + const {state} = this._history; + const hasState = typeof state === 'object' && state !== null; + /** @type {import('display').HistoryState} */ + const newState = ( hasState ? - clone(this._history.state) : + clone(state) : { focusEntry: 0, - optionsContext: null, + optionsContext: void 0, url: window.location.href, sentence: {text: query, offset: 0}, documentTitle: document.title } ); if (!hasState || updateOptionsContext) { - state.optionsContext = clone(this._optionsContext); + newState.optionsContext = clone(this._optionsContext); } + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode: 'clear', params: this._createSearchParams(type, query, false, this._queryOffset), - state, + state: newState, content: { - dictionaryEntries: null, contentOrigin: this.getContentOrigin() } }; this.setContent(details); } + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} [params] + * @returns {Promise<TReturn>} + */ async invokeContentOrigin(action, params={}) { if (this._contentOriginTabId === this._tabId && this._contentOriginFrameId === this._frameId) { throw new Error('Content origin is same page'); } + if (typeof this._contentOriginTabId !== 'number' || typeof this._contentOriginFrameId !== 'number') { + throw new Error('No content origin is assigned'); + } return await yomitan.crossFrame.invokeTab(this._contentOriginTabId, this._contentOriginFrameId, action, params); } + /** + * @template [TReturn=unknown] + * @param {string} action + * @param {import('core').SerializableObject} [params] + * @returns {Promise<TReturn>} + */ async invokeParentFrame(action, params={}) { if (this._parentFrameId === null || this._parentFrameId === this._frameId) { throw new Error('Invalid parent frame'); @@ -504,11 +608,17 @@ export class Display extends EventDispatcher { return await yomitan.crossFrame.invoke(this._parentFrameId, action, params); } + /** + * @param {Element} element + * @returns {number} + */ getElementDictionaryEntryIndex(element) { - const node = element.closest('.entry'); + const node = /** @type {?HTMLElement} */ (element.closest('.entry')); if (node === null) { return -1; } - const index = parseInt(node.dataset.index, 10); - return Number.isFinite(index) ? index : -1; + const {index} = node.dataset; + if (typeof index !== 'string') { return -1; } + const indexNumber = parseInt(index, 10); + return Number.isFinite(indexNumber) ? indexNumber : -1; } /** @@ -526,9 +636,13 @@ export class Display extends EventDispatcher { // Message handlers + /** + * @param {import('frame-client').Message<import('display').MessageDetails>} data + * @returns {import('core').MessageHandlerAsyncResult} + * @throws {Error} + */ _onDirectMessage(data) { - data = this._authenticateMessageData(data); - const {action, params} = data; + const {action, params} = this._authenticateMessageData(data); const handlerInfo = this._directMessageHandlers.get(action); if (typeof handlerInfo === 'undefined') { throw new Error(`Invalid action: ${action}`); @@ -536,17 +650,24 @@ export class Display extends EventDispatcher { const {async, handler} = handlerInfo; const result = handler(params); - return {async, result}; + return { + async: typeof async === 'boolean' && async, + result + }; } + /** + * @param {MessageEvent<import('frame-client').Message<import('display').MessageDetails>>} details + */ _onWindowMessage({data}) { + let data2; try { - data = this._authenticateMessageData(data); + data2 = this._authenticateMessageData(data); } catch (e) { return; } - const {action, params} = data; + const {action, params} = data2; const messageHandler = this._windowMessageHandlers.get(action); if (typeof messageHandler === 'undefined') { return; } @@ -554,23 +675,38 @@ export class Display extends EventDispatcher { invokeMessageHandler(messageHandler, params, callback); } + /** + * @param {{optionsContext: import('settings').OptionsContext}} details + */ async _onMessageSetOptionsContext({optionsContext}) { await this.setOptionsContext(optionsContext); this.searchLast(true); } + /** + * @param {{details: import('display').ContentDetails}} details + */ _onMessageSetContent({details}) { this.setContent(details); } + /** + * @param {{css: string}} details + */ _onMessageSetCustomCss({css}) { this.setCustomCss(css); } + /** + * @param {{scale: number}} details + */ _onMessageSetContentScale({scale}) { this._setContentScale(scale); } + /** + * @param {import('display').ConfigureMessageDetails} details + */ async _onMessageConfigure({depth, parentPopupId, parentFrameId, childrenSupported, scale, optionsContext}) { this._depth = depth; this._parentPopupId = parentPopupId; @@ -580,11 +716,17 @@ export class Display extends EventDispatcher { await this.setOptionsContext(optionsContext); } + /** + * @param {{value: boolean}} details + */ _onMessageVisibilityChanged({value}) { this._frameVisible = value; - this.trigger('frameVisibilityChange', {value}); + /** @type {import('display').FrameVisibilityChangeEvent} */ + const event = {value}; + this.trigger('frameVisibilityChange', event); } + /** */ _onMessageExtensionUnloaded() { if (yomitan.isExtensionUnloaded) { return; } yomitan.triggerExtensionUnloaded(); @@ -592,19 +734,27 @@ export class Display extends EventDispatcher { // Private + /** + * @template [T=unknown] + * @param {T|import('frame-client').Message<T>} data + * @returns {T} + * @throws {Error} + */ _authenticateMessageData(data) { if (this._frameEndpoint === null) { - return data; + return /** @type {T} */ (data); } if (!this._frameEndpoint.authenticate(data)) { throw new Error('Invalid authentication'); } - return data.data; + return /** @type {import('frame-client').Message<T>} */ (data).data; } + /** */ async _onStateChanged() { if (this._historyChangeIgnore) { return; } + /** @type {?import('core').TokenObject} */ const token = {}; // Unique identifier token this._setContentToken = token; try { @@ -628,15 +778,16 @@ export class Display extends EventDispatcher { this._queryParserVisibleOverride = (fullVisible === null ? null : (fullVisible !== 'false')); this._historyHasChanged = true; - this._contentType = type; // Set content switch (type) { case 'terms': case 'kanji': + this._contentType = type; await this._setContentTermsOrKanji(type, urlSearchParams, token); break; case 'unloaded': + this._contentType = type; this._setContentExtensionUnloaded(); break; default: @@ -645,18 +796,22 @@ export class Display extends EventDispatcher { break; } } catch (e) { - this.onError(e); + this.onError(e instanceof Error ? e : new Error(`${e}`)); } } + /** + * @param {import('display').QueryParserSearchedEvent} details + */ _onQueryParserSearch({type, dictionaryEntries, sentence, inputInfo: {eventType}, textSource, optionsContext, sentenceOffset}) { const query = textSource.text(); const historyState = this._history.state; const historyMode = ( eventType === 'click' || - !isObject(historyState) || + !(typeof historyState === 'object' && historyState !== null) || historyState.cause !== 'queryParser' ) ? 'new' : 'overwrite'; + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode, @@ -674,9 +829,11 @@ export class Display extends EventDispatcher { this.setContent(details); } + /** */ _onExtensionUnloaded() { const type = 'unloaded'; if (this._contentType === type) { return; } + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode: 'clear', @@ -692,21 +849,33 @@ export class Display extends EventDispatcher { this.setContent(details); } + /** + * @param {MouseEvent} e + */ _onCloseButtonClick(e) { e.preventDefault(); this.close(); } + /** + * @param {MouseEvent} e + */ _onSourceTermView(e) { e.preventDefault(); this._sourceTermView(); } + /** + * @param {MouseEvent} e + */ _onNextTermView(e) { e.preventDefault(); this._nextTermView(); } + /** + * @param {import('dynamic-property').ChangeEventDetails<boolean>} details + */ _onProgressIndicatorVisibleChanged({value}) { if (this._progressIndicatorTimer !== null) { clearTimeout(this._progressIndicatorTimer); @@ -726,17 +895,24 @@ export class Display extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ async _onKanjiLookup(e) { try { e.preventDefault(); - if (!this._historyHasState()) { return; } + const {state} = this._history; + if (!(typeof state === 'object' && state !== null)) { return; } - let {state: {sentence, url, documentTitle}} = this._history; + let {sentence, url, documentTitle} = state; if (typeof url !== 'string') { url = window.location.href; } if (typeof documentTitle !== 'string') { documentTitle = document.title; } const optionsContext = this.getOptionsContext(); - const query = e.currentTarget.textContent; + const element = /** @type {Element} */ (e.currentTarget); + let query = element.textContent; + if (query === null) { query = ''; } const dictionaryEntries = await yomitan.api.kanjiFind(query, optionsContext); + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode: 'new', @@ -755,10 +931,13 @@ export class Display extends EventDispatcher { }; this.setContent(details); } catch (error) { - this.onError(error); + this.onError(error instanceof Error ? error : new Error(`${error}`)); } } + /** + * @param {WheelEvent} e + */ _onWheel(e) { if (e.altKey) { if (e.deltaY !== 0) { @@ -770,6 +949,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {WheelEvent} e + */ _onHistoryWheel(e) { if (e.altKey) { return; } const delta = -e.deltaX || e.deltaY; @@ -784,12 +966,18 @@ export class Display extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ _onDebugLogClick(e) { - const link = e.currentTarget; + const link = /** @type {HTMLElement} */ (e.currentTarget); const index = this.getElementDictionaryEntryIndex(link); this._logDictionaryEntryData(index); } + /** + * @param {MouseEvent} e + */ _onDocumentElementMouseUp(e) { switch (e.button) { case 3: // Back @@ -805,6 +993,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ _onDocumentElementClick(e) { switch (e.button) { case 3: // Back @@ -822,27 +1013,43 @@ export class Display extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ _onEntryClick(e) { if (e.button !== 0) { return; } - const node = e.currentTarget; - const index = parseInt(node.dataset.index, 10); - if (!Number.isFinite(index)) { return; } - this._entrySetCurrent(index); + const node = /** @type {HTMLElement} */ (e.currentTarget); + const {index} = node.dataset; + if (typeof index !== 'string') { return; } + const indexNumber = parseInt(index, 10); + if (!Number.isFinite(indexNumber)) { return; } + this._entrySetCurrent(indexNumber); } + /** + * @param {MouseEvent} e + */ _onTagClick(e) { - this._showTagNotification(e.currentTarget); + const node = /** @type {HTMLElement} */ (e.currentTarget); + this._showTagNotification(node); } + /** + * @param {MouseEvent} e + */ _onMenuButtonClick(e) { - const node = e.currentTarget; + const node = /** @type {HTMLElement} */ (e.currentTarget); - const menuContainerNode = this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu'); - const menuBodyNode = menuContainerNode.querySelector('.popup-menu-body'); + const menuContainerNode = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu')); + const menuBodyNode = /** @type {HTMLElement} */ (menuContainerNode.querySelector('.popup-menu-body')); + /** + * @param {string} menuAction + * @param {string} label + */ const addItem = (menuAction, label) => { - const item = this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu-item'); - item.querySelector('.popup-menu-item-label').textContent = label; + const item = /** @type {HTMLElement} */ (this._displayGenerator.instantiateTemplate('dictionary-entry-popup-menu-item')); + /** @type {HTMLElement} */ (item.querySelector('.popup-menu-item-label')).textContent = label; item.dataset.menuAction = menuAction; menuBodyNode.appendChild(item); }; @@ -854,8 +1061,12 @@ export class Display extends EventDispatcher { popupMenu.prepare(); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuButtonMenuClose(e) { - const {currentTarget: node, detail: {action}} = e; + const node = /** @type {HTMLElement} */ (e.currentTarget); + const {action} = e.detail; switch (action) { case 'log-debug-info': this._logDictionaryEntryData(this.getElementDictionaryEntryIndex(node)); @@ -863,27 +1074,36 @@ export class Display extends EventDispatcher { } } + /** + * @param {Element} tagNode + */ _showTagNotification(tagNode) { - tagNode = tagNode.parentNode; - if (tagNode === null) { return; } + const parent = tagNode.parentNode; + if (parent === null || !(parent instanceof HTMLElement)) { return; } if (this._tagNotification === null) { this._tagNotification = this.createNotification(true); } - const index = this.getElementDictionaryEntryIndex(tagNode); + const index = this.getElementDictionaryEntryIndex(parent); const dictionaryEntry = (index >= 0 && index < this._dictionaryEntries.length ? this._dictionaryEntries[index] : null); - const content = this._displayGenerator.createTagFooterNotificationDetails(tagNode, dictionaryEntry); + const content = this._displayGenerator.createTagFooterNotificationDetails(parent, dictionaryEntry); this._tagNotification.setContent(content); this._tagNotification.open(); } + /** + * @param {boolean} animate + */ _hideTagNotification(animate) { if (this._tagNotification === null) { return; } this._tagNotification.close(animate); } + /** + * @param {import('settings').ProfileOptions} options + */ _updateDocumentOptions(options) { const data = document.documentElement.dataset; data.ankiEnabled = `${options.anki.enable}`; @@ -903,6 +1123,9 @@ export class Display extends EventDispatcher { data.popupActionBarLocation = `${options.general.popupActionBarLocation}`; } + /** + * @param {import('settings').ProfileOptions} options + */ _setTheme(options) { const {general} = options; const {popupTheme} = general; @@ -912,11 +1135,19 @@ export class Display extends EventDispatcher { this.setCustomCss(general.customPopupCss); } + /** + * @param {boolean} isKanji + * @param {string} source + * @param {boolean} wildcardsEnabled + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<import('dictionary').DictionaryEntry[]>} + */ async _findDictionaryEntries(isKanji, source, wildcardsEnabled, optionsContext) { if (isKanji) { const dictionaryEntries = await yomitan.api.kanjiFind(source, optionsContext); return dictionaryEntries; } else { + /** @type {import('api').FindTermsDetails} */ const findDetails = {}; if (wildcardsEnabled) { const match = /^([*\uff0a]*)([\w\W]*?)([*\uff0a]*)$/.exec(source); @@ -937,6 +1168,11 @@ export class Display extends EventDispatcher { } } + /** + * @param {string} type + * @param {URLSearchParams} urlSearchParams + * @param {import('core').TokenObject} token + */ async _setContentTermsOrKanji(type, urlSearchParams, token) { const lookup = (urlSearchParams.get('lookup') !== 'false'); const wildcardsEnabled = (urlSearchParams.get('wildcards') !== 'off'); @@ -946,20 +1182,21 @@ export class Display extends EventDispatcher { if (query === null) { query = ''; } let queryFull = urlSearchParams.get('full'); queryFull = (queryFull !== null ? queryFull : query); - let queryOffset = urlSearchParams.get('offset'); - if (queryOffset !== null) { - queryOffset = Number.parseInt(queryOffset, 10); - queryOffset = Number.isFinite(queryOffset) ? Math.max(0, Math.min(queryFull.length - query.length, queryOffset)) : null; + const queryOffsetString = urlSearchParams.get('offset'); + let queryOffset = 0; + if (queryOffsetString !== null) { + queryOffset = Number.parseInt(queryOffsetString, 10); + queryOffset = Number.isFinite(queryOffset) ? Math.max(0, Math.min(queryFull.length - query.length, queryOffset)) : 0; } this._setQuery(query, queryFull, queryOffset); let {state, content} = this._history; let changeHistory = false; - if (!isObject(content)) { + if (!(typeof content === 'object' && content !== null)) { content = {}; changeHistory = true; } - if (!isObject(state)) { + if (!(typeof state === 'object' && state !== null)) { state = {}; changeHistory = true; } @@ -1052,8 +1289,9 @@ export class Display extends EventDispatcher { this._triggerContentUpdateComplete(); } + /** */ _setContentExtensionUnloaded() { - const errorExtensionUnloaded = document.querySelector('#error-extension-unloaded'); + const errorExtensionUnloaded = /** @type {?HTMLElement} */ (document.querySelector('#error-extension-unloaded')); if (this._container !== null) { this._container.hidden = true; @@ -1071,6 +1309,7 @@ export class Display extends EventDispatcher { this._triggerContentUpdateComplete(); } + /** */ _clearContent() { this._container.textContent = ''; this._updateNavigationAuto(); @@ -1080,14 +1319,22 @@ export class Display extends EventDispatcher { this._triggerContentUpdateComplete(); } + /** + * @param {boolean} visible + */ _setNoContentVisible(visible) { - const noResults = document.querySelector('#no-results'); + const noResults = /** @type {?HTMLElement} */ (document.querySelector('#no-results')); if (noResults !== null) { noResults.hidden = !visible; } } + /** + * @param {string} query + * @param {string} fullQuery + * @param {number} queryOffset + */ _setQuery(query, fullQuery, queryOffset) { this._query = query; this._fullQuery = fullQuery; @@ -1096,6 +1343,7 @@ export class Display extends EventDispatcher { this._setTitleText(query); } + /** */ _updateQueryParser() { const text = this._fullQuery; const visible = this._isQueryParserVisible(); @@ -1105,6 +1353,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {string} text + */ async _setQueryParserText(text) { const overrideToken = this._progressIndicatorVisible.setOverride(true); try { @@ -1114,6 +1365,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {string} text + */ _setTitleText(text) { let title = this._defaultTitle; if (text.length > 0) { @@ -1130,10 +1384,15 @@ export class Display extends EventDispatcher { document.title = title; } + /** */ _updateNavigationAuto() { this._updateNavigation(this._history.hasPrevious(), this._history.hasNext()); } + /** + * @param {boolean} previous + * @param {boolean} next + */ _updateNavigation(previous, next) { const {documentElement} = document; if (documentElement !== null) { @@ -1148,6 +1407,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {number} index + */ _entrySetCurrent(index) { const entryPre = this._getEntry(this._index); if (entryPre !== null) { @@ -1162,6 +1424,11 @@ export class Display extends EventDispatcher { this._index = index; } + /** + * @param {number} index + * @param {number} definitionIndex + * @param {boolean} smooth + */ _focusEntry(index, definitionIndex, smooth) { index = Math.max(Math.min(index, this._dictionaryEntries.length - 1), 0); @@ -1188,6 +1455,11 @@ export class Display extends EventDispatcher { } } + /** + * @param {number} offset + * @param {boolean} smooth + * @returns {boolean} + */ _focusEntryWithDifferentDictionary(offset, smooth) { const sign = Math.sign(offset); if (sign === 0) { return false; } @@ -1200,18 +1472,22 @@ export class Display extends EventDispatcher { const visibleDefinitionIndex = this._getDictionaryEntryVisibleDefinitionIndex(index, sign); if (visibleDefinitionIndex === null) { return false; } - const {dictionary} = dictionaryEntry.definitions[visibleDefinitionIndex]; let focusDefinitionIndex = null; - for (let i = index; i >= 0 && i < count; i += sign) { - const {definitions} = this._dictionaryEntries[i]; - const jj = definitions.length; - let j = (i === index ? visibleDefinitionIndex + sign : (sign > 0 ? 0 : jj - 1)); - for (; j >= 0 && j < jj; j += sign) { - if (definitions[j].dictionary !== dictionary) { - focusDefinitionIndex = j; - index = i; - i = -2; // Terminate outer loop - break; + if (dictionaryEntry.type === 'term') { + const {dictionary} = dictionaryEntry.definitions[visibleDefinitionIndex]; + for (let i = index; i >= 0 && i < count; i += sign) { + const otherDictionaryEntry = this._dictionaryEntries[i]; + if (otherDictionaryEntry.type !== 'term') { continue; } + const {definitions} = otherDictionaryEntry; + const jj = definitions.length; + let j = (i === index ? visibleDefinitionIndex + sign : (sign > 0 ? 0 : jj - 1)); + for (; j >= 0 && j < jj; j += sign) { + if (definitions[j].dictionary !== dictionary) { + focusDefinitionIndex = j; + index = i; + i = -2; // Terminate outer loop + break; + } } } } @@ -1222,6 +1498,11 @@ export class Display extends EventDispatcher { return true; } + /** + * @param {number} index + * @param {number} sign + * @returns {?number} + */ _getDictionaryEntryVisibleDefinitionIndex(index, sign) { const {top: scrollTop, bottom: scrollBottom} = this._windowScroll.getRect(); @@ -1247,18 +1528,28 @@ export class Display extends EventDispatcher { return visibleIndex !== null ? visibleIndex : (sign > 0 ? definitionCount - 1 : 0); } + /** + * @param {number} index + * @returns {NodeListOf<HTMLElement>} + */ _getDictionaryEntryDefinitionNodes(index) { return this._dictionaryEntryNodes[index].querySelectorAll('.definition-item'); } + /** */ _sourceTermView() { this._relativeTermView(false); } + /** */ _nextTermView() { this._relativeTermView(true); } + /** + * @param {boolean} next + * @returns {boolean} + */ _relativeTermView(next) { if (next) { return this._history.hasNext() && this._history.forward(); @@ -1267,24 +1558,29 @@ export class Display extends EventDispatcher { } } + /** + * @param {number} index + * @returns {?HTMLElement} + */ _getEntry(index) { const entries = this._dictionaryEntryNodes; return index >= 0 && index < entries.length ? entries[index] : null; } + /** + * @param {Element} element + * @returns {number} + */ _getElementTop(element) { const elementRect = element.getBoundingClientRect(); const documentRect = this._contentScrollBodyElement.getBoundingClientRect(); return elementRect.top - documentRect.top; } - _historyHasState() { - return isObject(this._history.state); - } - + /** */ _updateHistoryState() { const {state, content} = this._history; - if (!isObject(state)) { return; } + if (!(typeof state === 'object' && state !== null)) { return; } state.focusEntry = this._index; state.scrollX = this._windowScroll.x; @@ -1292,6 +1588,10 @@ export class Display extends EventDispatcher { this._replaceHistoryStateNoNavigate(state, content); } + /** + * @param {import('display-history').EntryState} state + * @param {?import('display-history').EntryContent} content + */ _replaceHistoryStateNoNavigate(state, content) { const historyChangeIgnorePre = this._historyChangeIgnore; try { @@ -1302,7 +1602,15 @@ export class Display extends EventDispatcher { } } + /** + * @param {import('display').PageType} type + * @param {string} query + * @param {boolean} wildcards + * @param {?number} sentenceOffset + * @returns {import('display').HistoryParams} + */ _createSearchParams(type, query, wildcards, sentenceOffset) { + /** @type {import('display').HistoryParams} */ const params = {}; const fullQuery = this._fullQuery; const includeFull = (query.length < fullQuery.length); @@ -1325,6 +1633,9 @@ export class Display extends EventDispatcher { return params; } + /** + * @returns {boolean} + */ _isQueryParserVisible() { return ( this._queryParserVisibleOverride !== null ? @@ -1333,22 +1644,34 @@ export class Display extends EventDispatcher { ); } + /** */ _closePopups() { yomitan.trigger('closePopups'); } + /** + * @param {import('settings').OptionsContext} optionsContext + */ async _setOptionsContextIfDifferent(optionsContext) { if (deepEqual(this._optionsContext, optionsContext)) { return; } await this.setOptionsContext(optionsContext); } + /** + * @param {number} scale + */ _setContentScale(scale) { const body = document.body; if (body === null) { return; } body.style.fontSize = `${scale}em`; } + /** + * @param {import('settings').ProfileOptions} options + */ async _updateNestedFrontend(options) { + if (typeof this._frameId !== 'number') { return; } + const isSearchPage = (this._pageType === 'search'); const isEnabled = ( this._childrenSupported && @@ -1376,15 +1699,18 @@ export class Display extends EventDispatcher { } } - this._frontend.setDisabledOverride(!isEnabled); + /** @type {Frontend} */ (this._frontend).setDisabledOverride(!isEnabled); } + /** */ async _setupNestedFrontend() { - const setupNestedPopupsOptions = { - useProxyPopup: this._parentFrameId !== null, - parentPopupId: this._parentPopupId, - parentFrameId: this._parentFrameId - }; + if (typeof this._frameId !== 'number') { + throw new Error('No frameId assigned'); + } + + const useProxyPopup = this._parentFrameId !== null; + const parentPopupId = this._parentPopupId; + const parentFrameId = this._parentFrameId; await dynamicLoader.loadScripts([ '/js/language/text-scanner.js', @@ -1401,7 +1727,11 @@ export class Display extends EventDispatcher { const popupFactory = new PopupFactory(this._frameId); popupFactory.prepare(); - Object.assign(setupNestedPopupsOptions, { + /** @type {import('frontend').ConstructorDetails} */ + const setupNestedPopupsOptions = { + useProxyPopup, + parentPopupId, + parentFrameId, depth: this._depth + 1, tabId: this._tabId, frameId: this._frameId, @@ -1410,19 +1740,25 @@ export class Display extends EventDispatcher { allowRootFramePopupProxy: true, childrenSupported: this._childrenSupported, hotkeyHandler: this._hotkeyHandler - }); + }; const frontend = new Frontend(setupNestedPopupsOptions); this._frontend = frontend; await frontend.prepare(); } + /** + * @returns {boolean} + */ _copyHostSelection() { - if (this._contentOriginFrameId === null || window.getSelection().toString()) { return false; } + if (typeof this._contentOriginFrameId !== 'number') { return false; } + const selection = window.getSelection(); + if (selection !== null && selection.toString().length > 0) { return false; } this._copyHostSelectionSafe(); return true; } + /** */ async _copyHostSelectionSafe() { try { await this._copyHostSelectionInner(); @@ -1431,11 +1767,13 @@ export class Display extends EventDispatcher { } } + /** */ async _copyHostSelectionInner() { switch (this._browser) { case 'firefox': case 'firefox-mobile': { + /** @type {string} */ let text; try { text = await this.invokeContentOrigin('Frontend.getSelectionText'); @@ -1451,6 +1789,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {string} text + */ _copyText(text) { const parent = document.body; if (parent === null) { return; } @@ -1468,6 +1809,9 @@ export class Display extends EventDispatcher { parent.removeChild(textarea); } + /** + * @param {HTMLElement} entry + */ _addEntryEventListeners(entry) { const eventListeners = this._eventListeners; eventListeners.addEventListener(entry, 'click', this._onEntryClickBind); @@ -1483,6 +1827,9 @@ export class Display extends EventDispatcher { } } + /** + * @param {import('settings').ProfileOptions} options + */ _updateContentTextScanner(options) { if (!options.scanning.enablePopupSearch) { if (this._contentTextScanner !== null) { @@ -1544,10 +1891,14 @@ export class Display extends EventDispatcher { this._contentTextScanner.setEnabled(true); } + /** */ _onContentTextScannerClear() { - this._contentTextScanner.clearSelection(); + /** @type {TextScanner} */ (this._contentTextScanner).clearSelection(); } + /** + * @param {import('text-scanner').SearchedEventDetails} details + */ _onContentTextScannerSearched({type, dictionaryEntries, sentence, textSource, optionsContext, error}) { if (error !== null && !yomitan.isExtensionUnloaded) { log.error(error); @@ -1558,6 +1909,7 @@ export class Display extends EventDispatcher { const query = textSource.text(); const url = window.location.href; const documentTitle = document.title; + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode: 'new', @@ -1568,28 +1920,35 @@ export class Display extends EventDispatcher { }, state: { focusEntry: 0, - optionsContext, + optionsContext: optionsContext !== null ? optionsContext : void 0, url, - sentence, + sentence: sentence !== null ? sentence : void 0, documentTitle }, content: { - dictionaryEntries, + dictionaryEntries: dictionaryEntries !== null ? dictionaryEntries : void 0, contentOrigin: this.getContentOrigin() } }; - this._contentTextScanner.clearSelection(); + /** @type {TextScanner} */ (this._contentTextScanner).clearSelection(); this.setContent(details); } + /** + * @type {import('display').GetSearchContextCallback} + */ _getSearchContext() { return {optionsContext: this.getOptionsContext()}; } + /** + * @param {import('settings').ProfileOptions} options + */ _updateHotkeys(options) { this._hotkeyHandler.setHotkeys(this._pageType, options.inputs.hotkeys); } + /** */ async _closeTab() { const tab = await new Promise((resolve, reject) => { chrome.tabs.getCurrent((result) => { @@ -1602,7 +1961,7 @@ export class Display extends EventDispatcher { }); }); const tabId = tab.id; - await new Promise((resolve, reject) => { + await /** @type {Promise<void>} */ (new Promise((resolve, reject) => { chrome.tabs.remove(tabId, () => { const e = chrome.runtime.lastError; if (e) { @@ -1611,27 +1970,36 @@ export class Display extends EventDispatcher { resolve(); } }); - }); + })); } + /** */ _onHotkeyClose() { if (this._closeSinglePopupMenu()) { return; } this.close(); } + /** + * @param {number} sign + * @param {unknown} argument + */ _onHotkeyActionMoveRelative(sign, argument) { - let count = Number.parseInt(argument, 10); + let count = typeof argument === 'number' ? argument : (typeof argument === 'string' ? Number.parseInt(argument, 10) : 0); if (!Number.isFinite(count)) { count = 1; } count = Math.max(0, Math.floor(count)); this._focusEntry(this._index + count * sign, 0, true); } + /** */ _closeAllPopupMenus() { for (const popupMenu of PopupMenu.openMenus) { popupMenu.close(); } } + /** + * @returns {boolean} + */ _closeSinglePopupMenu() { for (const popupMenu of PopupMenu.openMenus) { popupMenu.close(); @@ -1640,13 +2008,19 @@ export class Display extends EventDispatcher { return false; } + /** + * @param {number} index + */ async _logDictionaryEntryData(index) { if (index < 0 || index >= this._dictionaryEntries.length) { return; } const dictionaryEntry = this._dictionaryEntries[index]; const result = {dictionaryEntry}; + /** @type {Promise<unknown>[]} */ const promises = []; - this.trigger('logDictionaryEntryData', {dictionaryEntry, promises}); + /** @type {import('display').LogDictionaryEntryDataEvent} */ + const event = {dictionaryEntry, promises}; + this.trigger('logDictionaryEntryData', event); if (promises.length > 0) { for (const result2 of await Promise.all(promises)) { Object.assign(result, result2); @@ -1656,19 +2030,33 @@ export class Display extends EventDispatcher { console.log(result); } + /** */ _triggerContentClear() { this.trigger('contentClear', {}); } + /** */ _triggerContentUpdateStart() { - this.trigger('contentUpdateStart', {type: this._contentType, query: this._query}); + /** @type {import('display').ContentUpdateStartEvent} */ + const event = {type: this._contentType, query: this._query}; + this.trigger('contentUpdateStart', event); } + /** + * @param {import('dictionary').DictionaryEntry} dictionaryEntry + * @param {Element} element + * @param {number} index + */ _triggerContentUpdateEntry(dictionaryEntry, element, index) { - this.trigger('contentUpdateEntry', {dictionaryEntry, element, index}); + /** @type {import('display').ContentUpdateEntryEvent} */ + const event = {dictionaryEntry, element, index}; + this.trigger('contentUpdateEntry', event); } + /** */ _triggerContentUpdateComplete() { - this.trigger('contentUpdateComplete', {type: this._contentType}); + /** @type {import('display').ContentUpdateCompleteEvent} */ + const event = {type: this._contentType}; + this.trigger('contentUpdateComplete', event); } } diff --git a/ext/js/display/element-overflow-controller.js b/ext/js/display/element-overflow-controller.js index 0a62906b..35277fa5 100644 --- a/ext/js/display/element-overflow-controller.js +++ b/ext/js/display/element-overflow-controller.js @@ -20,16 +20,27 @@ import {EventListenerCollection} from '../core.js'; export class ElementOverflowController { constructor() { + /** @type {Element[]} */ this._elements = []; + /** @type {?(number|import('core').Timeout)} */ this._checkTimer = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {EventListenerCollection} */ this._windowEventListeners = new EventListenerCollection(); + /** @type {Map<string, {collapsed: boolean, force: boolean}>} */ this._dictionaries = new Map(); + /** @type {() => void} */ this._updateBind = this._update.bind(this); + /** @type {() => void} */ this._onWindowResizeBind = this._onWindowResize.bind(this); + /** @type {(event: MouseEvent) => void} */ this._onToggleButtonClickBind = this._onToggleButtonClick.bind(this); } + /** + * @param {import('settings').ProfileOptions} options + */ setOptions(options) { this._dictionaries.clear(); for (const {name, definitionsCollapsible} of options.dictionaries) { @@ -59,12 +70,18 @@ export class ElementOverflowController { } } + /** + * @param {Element} entry + */ addElements(entry) { if (this._dictionaries.size === 0) { return; } const elements = entry.querySelectorAll('.definition-item-inner'); for (const element of elements) { - const {dictionary} = element.parentNode.dataset; + const {parentNode} = element; + if (parentNode === null) { continue; } + const {dictionary} = /** @type {HTMLElement} */ (parentNode).dataset; + if (typeof dictionary === 'undefined') { continue; } const dictionaryInfo = this._dictionaries.get(dictionary); if (typeof dictionaryInfo === 'undefined') { continue; } @@ -90,6 +107,7 @@ export class ElementOverflowController { } } + /** */ clearElements() { this._elements.length = 0; this._windowEventListeners.removeAllEventListeners(); @@ -97,6 +115,7 @@ export class ElementOverflowController { // Private + /** */ _onWindowResize() { if (this._checkTimer !== null) { this._cancelIdleCallback(this._checkTimer); @@ -104,18 +123,26 @@ export class ElementOverflowController { this._checkTimer = this._requestIdleCallback(this._updateBind, 100); } + /** + * @param {MouseEvent} e + */ _onToggleButtonClick(e) { - const container = e.currentTarget.closest('.definition-item-inner'); + const element = /** @type {Element} */ (e.currentTarget); + const container = element.closest('.definition-item-inner'); if (container === null) { return; } container.classList.toggle('collapsed'); } + /** */ _update() { for (const element of this._elements) { this._updateElement(element); } } + /** + * @param {Element} element + */ _updateElement(element) { const {classList} = element; classList.add('collapse-test'); @@ -124,6 +151,11 @@ export class ElementOverflowController { classList.remove('collapse-test'); } + /** + * @param {() => void} callback + * @param {number} timeout + * @returns {number|import('core').Timeout} + */ _requestIdleCallback(callback, timeout) { if (typeof requestIdleCallback === 'function') { return requestIdleCallback(callback, {timeout}); @@ -132,9 +164,12 @@ export class ElementOverflowController { } } + /** + * @param {number|import('core').Timeout} handle + */ _cancelIdleCallback(handle) { if (typeof cancelIdleCallback === 'function') { - cancelIdleCallback(handle); + cancelIdleCallback(/** @type {number} */ (handle)); } else { clearTimeout(handle); } diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js index 1f8de939..edd7de5b 100644 --- a/ext/js/display/option-toggle-hotkey-handler.js +++ b/ext/js/display/option-toggle-hotkey-handler.js @@ -16,17 +16,28 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError} from '../core.js'; +import {generateId} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {yomitan} from '../yomitan.js'; export class OptionToggleHotkeyHandler { + /** + * @param {import('./display.js').Display} display + */ constructor(display) { + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {?import('./display-notification.js').DisplayNotification} */ this._notification = null; + /** @type {?import('core').Timeout} */ this._notificationHideTimer = null; + /** @type {number} */ this._notificationHideTimeout = 5000; + /** @type {string} */ + this._source = `option-toggle-hotkey-handler-${generateId(16)}`; } + /** @type {number} */ get notificationHideTimeout() { return this._notificationHideTimeout; } @@ -35,6 +46,7 @@ export class OptionToggleHotkeyHandler { this._notificationHideTimeout = value; } + /** */ prepare() { this._display.hotkeyHandler.registerActions([ ['toggleOption', this._onHotkeyActionToggleOption.bind(this)] @@ -43,10 +55,17 @@ export class OptionToggleHotkeyHandler { // Private + /** + * @param {unknown} argument + */ _onHotkeyActionToggleOption(argument) { + if (typeof argument !== 'string') { return; } this._toggleOption(argument); } + /** + * @param {string} path + */ async _toggleOption(path) { let value; try { @@ -59,7 +78,7 @@ export class OptionToggleHotkeyHandler { }]))[0]; const {error} = result; if (typeof error !== 'undefined') { - throw deserializeError(error); + throw ExtensionError.deserialize(error); } value = result.result; @@ -69,16 +88,18 @@ export class OptionToggleHotkeyHandler { value = !value; - const result2 = (await yomitan.api.modifySettings([{ + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { scope: 'profile', action: 'set', path, value, optionsContext - }]))[0]; + }; + const result2 = (await yomitan.api.modifySettings([modification], this._source))[0]; const {error: error2} = result2; if (typeof error2 !== 'undefined') { - throw deserializeError(error2); + throw ExtensionError.deserialize(error2); } this._showNotification(this._createSuccessMessage(path, value), true); @@ -87,12 +108,17 @@ export class OptionToggleHotkeyHandler { } } + /** + * @param {string} path + * @param {unknown} value + * @returns {DocumentFragment} + */ _createSuccessMessage(path, value) { const fragment = document.createDocumentFragment(); const n1 = document.createElement('em'); n1.textContent = path; const n2 = document.createElement('strong'); - n2.textContent = value; + n2.textContent = `${value}`; fragment.appendChild(document.createTextNode('Option ')); fragment.appendChild(n1); fragment.appendChild(document.createTextNode(' changed to ')); @@ -100,17 +126,13 @@ export class OptionToggleHotkeyHandler { return fragment; } + /** + * @param {string} path + * @param {unknown} error + * @returns {DocumentFragment} + */ _createErrorMessage(path, error) { - let message; - try { - ({message} = error); - } catch (e) { - // NOP - } - if (typeof message !== 'string') { - message = `${error}`; - } - + const message = error instanceof Error ? error.message : `${error}`; const fragment = document.createDocumentFragment(); const n1 = document.createElement('em'); n1.textContent = path; @@ -124,6 +146,10 @@ export class OptionToggleHotkeyHandler { return fragment; } + /** + * @param {DocumentFragment} message + * @param {boolean} autoClose + */ _showNotification(message, autoClose) { if (this._notification === null) { this._notification = this._display.createNotification(false); @@ -139,12 +165,16 @@ export class OptionToggleHotkeyHandler { } } + /** + * @param {boolean} animate + */ _hideNotification(animate) { if (this._notification === null) { return; } this._notification.close(animate); this._stopHideNotificationTimer(); } + /** */ _stopHideNotificationTimer() { if (this._notificationHideTimer !== null) { clearTimeout(this._notificationHideTimer); @@ -152,11 +182,13 @@ export class OptionToggleHotkeyHandler { } } + /** */ _onNotificationHideTimeout() { this._notificationHideTimer = null; this._hideNotification(true); } + /** */ _onNotificationClick() { this._stopHideNotificationTimer(); } diff --git a/ext/js/display/query-parser.js b/ext/js/display/query-parser.js index 85ec3031..03b54fd5 100644 --- a/ext/js/display/query-parser.js +++ b/ext/js/display/query-parser.js @@ -20,22 +20,42 @@ import {EventDispatcher, log} from '../core.js'; import {TextScanner} from '../language/text-scanner.js'; import {yomitan} from '../yomitan.js'; +/** + * @augments EventDispatcher<import('display').QueryParserEventType> + */ export class QueryParser extends EventDispatcher { + /** + * @param {import('display').QueryParserConstructorDetails} details + */ constructor({getSearchContext, japaneseUtil}) { super(); + /** @type {import('display').GetSearchContextCallback} */ this._getSearchContext = getSearchContext; + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {string} */ this._text = ''; + /** @type {?import('core').TokenObject} */ this._setTextToken = null; + /** @type {?string} */ this._selectedParser = null; + /** @type {import('settings').ParsingReadingMode} */ this._readingMode = 'none'; + /** @type {number} */ this._scanLength = 1; + /** @type {boolean} */ this._useInternalParser = true; + /** @type {boolean} */ this._useMecabParser = false; + /** @type {import('api').ParseTextResult} */ this._parseResults = []; - this._queryParser = document.querySelector('#query-parser-content'); - this._queryParserModeContainer = document.querySelector('#query-parser-mode-container'); - this._queryParserModeSelect = document.querySelector('#query-parser-mode-select'); + /** @type {HTMLElement} */ + this._queryParser = /** @type {HTMLElement} */ (document.querySelector('#query-parser-content')); + /** @type {HTMLElement} */ + this._queryParserModeContainer = /** @type {HTMLElement} */ (document.querySelector('#query-parser-mode-container')); + /** @type {HTMLSelectElement} */ + this._queryParserModeSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#query-parser-mode-select')); + /** @type {TextScanner} */ this._textScanner = new TextScanner({ node: this._queryParser, getSearchContext, @@ -45,10 +65,12 @@ export class QueryParser extends EventDispatcher { }); } + /** @type {string} */ get text() { return this._text; } + /** */ prepare() { this._textScanner.prepare(); this._textScanner.on('clear', this._onTextScannerClear.bind(this)); @@ -56,6 +78,9 @@ export class QueryParser extends EventDispatcher { this._queryParserModeSelect.addEventListener('change', this._onParserChange.bind(this), false); } + /** + * @param {import('display').QueryParserOptions} display + */ setOptions({selectedParser, termSpacing, readingMode, useInternalParser, useMecabParser, scanning}) { let selectedParserChanged = false; if (selectedParser === null || typeof selectedParser === 'string') { @@ -87,10 +112,14 @@ export class QueryParser extends EventDispatcher { } } + /** + * @param {string} text + */ async setText(text) { this._text = text; this._setPreview(text); + /** @type {?import('core').TokenObject} */ const token = {}; this._setTextToken = token; this._parseResults = await yomitan.api.parseText(text, this._getOptionsContext(), this._scanLength, this._useInternalParser, this._useMecabParser); @@ -104,32 +133,63 @@ export class QueryParser extends EventDispatcher { // Private + /** */ _onTextScannerClear() { this._textScanner.clearSelection(); } + /** + * @param {import('text-scanner').SearchedEventDetails} e + */ _onSearched(e) { const {error} = e; if (error !== null) { log.error(error); return; } - if (e.type === null) { return; } - - e.sentenceOffset = this._getSentenceOffset(e.textSource); - this.trigger('searched', e); + const { + textScanner, + type, + dictionaryEntries, + sentence, + inputInfo, + textSource, + optionsContext + } = e; + if (type === null || dictionaryEntries === null || sentence === null || optionsContext === null) { return; } + + /** @type {import('display').QueryParserSearchedEvent} */ + const event2 = { + textScanner, + type, + dictionaryEntries, + sentence, + inputInfo, + textSource, + optionsContext, + sentenceOffset: this._getSentenceOffset(e.textSource) + }; + this.trigger('searched', event2); } + /** + * @param {Event} e + */ _onParserChange(e) { - const value = e.currentTarget.value; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + const value = element.value; this._setSelectedParser(value); } + /** + * @returns {import('settings').OptionsContext} + */ _getOptionsContext() { return this._getSearchContext().optionsContext; } + /** */ _refreshSelectedParser() { if (this._parseResults.length > 0 && !this._getParseResult()) { const value = this._parseResults[0].id; @@ -137,22 +197,33 @@ export class QueryParser extends EventDispatcher { } } + /** + * @param {string} value + */ _setSelectedParser(value) { const optionsContext = this._getOptionsContext(); - yomitan.api.modifySettings([{ + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'parsing.selectedParser', value, scope: 'profile', optionsContext - }], 'search'); + }; + yomitan.api.modifySettings([modification], 'search'); } + /** + * @returns {import('api').ParseTextResultItem|undefined} + */ _getParseResult() { const selectedParser = this._selectedParser; return this._parseResults.find((r) => r.id === selectedParser); } + /** + * @param {string} text + */ _setPreview(text) { const terms = [[{text, reading: ''}]]; this._queryParser.textContent = ''; @@ -160,6 +231,7 @@ export class QueryParser extends EventDispatcher { this._queryParser.appendChild(this._createParseResult(terms)); } + /** */ _renderParserSelect() { const visible = (this._parseResults.length > 1); if (visible) { @@ -168,6 +240,7 @@ export class QueryParser extends EventDispatcher { this._queryParserModeContainer.hidden = !visible; } + /** */ _renderParseResult() { const parseResult = this._getParseResult(); this._queryParser.textContent = ''; @@ -176,6 +249,11 @@ export class QueryParser extends EventDispatcher { this._queryParser.appendChild(this._createParseResult(parseResult.content)); } + /** + * @param {HTMLSelectElement} select + * @param {import('api').ParseTextResult} parseResults + * @param {?string} selectedParser + */ _updateParserModeSelect(select, parseResults, selectedParser) { const fragment = document.createDocumentFragment(); @@ -208,6 +286,10 @@ export class QueryParser extends EventDispatcher { select.selectedIndex = selectedIndex; } + /** + * @param {import('api').ParseTextLine[]} data + * @returns {DocumentFragment} + */ _createParseResult(data) { let offset = 0; const fragment = document.createDocumentFragment(); @@ -229,6 +311,12 @@ export class QueryParser extends EventDispatcher { return fragment; } + /** + * @param {string} text + * @param {string} reading + * @param {number} offset + * @returns {HTMLElement} + */ _createSegment(text, reading, offset) { const segmentNode = document.createElement('ruby'); segmentNode.className = 'query-parser-segment'; @@ -249,6 +337,11 @@ export class QueryParser extends EventDispatcher { return segmentNode; } + /** + * @param {string} term + * @param {string} reading + * @returns {string} + */ _convertReading(term, reading) { switch (this._readingMode) { case 'hiragana': @@ -271,11 +364,15 @@ export class QueryParser extends EventDispatcher { } } + /** + * @param {import('text-source').TextSource} textSource + * @returns {?number} + */ _getSentenceOffset(textSource) { if (textSource.type === 'range') { const {range} = textSource; const node = this._getParentElement(range.startContainer); - if (node !== null) { + if (node !== null && node instanceof HTMLElement) { const {offset} = node.dataset; if (typeof offset === 'string') { const value = Number.parseInt(offset, 10); @@ -288,12 +385,16 @@ export class QueryParser extends EventDispatcher { return null; } + /** + * @param {?Node} node + * @returns {?Element} + */ _getParentElement(node) { const {ELEMENT_NODE} = Node; while (true) { - node = node.parentNode; if (node === null) { return null; } - if (node.nodeType === ELEMENT_NODE) { return node; } + if (node.nodeType === ELEMENT_NODE) { return /** @type {Element} */ (node); } + node = node.parentNode; } } } diff --git a/ext/js/display/sandbox/pronunciation-generator.js b/ext/js/display/sandbox/pronunciation-generator.js index 76d5e2b1..d113485f 100644 --- a/ext/js/display/sandbox/pronunciation-generator.js +++ b/ext/js/display/sandbox/pronunciation-generator.js @@ -17,10 +17,21 @@ */ export class PronunciationGenerator { + /** + * @param {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + */ constructor(japaneseUtil) { + /** @type {import('../../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; } + /** + * @param {string[]} morae + * @param {number} downstepPosition + * @param {number[]} nasalPositions + * @param {number[]} devoicePositions + * @returns {HTMLSpanElement} + */ createPronunciationText(morae, downstepPosition, nasalPositions, devoicePositions) { const jp = this._japaneseUtil; const nasalPositionsSet = nasalPositions.length > 0 ? new Set(nasalPositions) : null; @@ -63,7 +74,7 @@ export class PronunciationGenerator { group.className = 'pronunciation-character-group'; const n2 = characterNodes[0]; - const character = n2.textContent; + const character = /** @type {string} */ (n2.textContent); const characterInfo = jp.getKanaDiacriticInfo(character); if (characterInfo !== null) { @@ -81,7 +92,7 @@ export class PronunciationGenerator { n3.className = 'pronunciation-nasal-indicator'; group.appendChild(n3); - n2.parentNode.replaceChild(group, n2); + /** @type {ParentNode} */ (n2.parentNode).replaceChild(group, n2); group.insertBefore(n2, group.firstChild); } @@ -94,6 +105,11 @@ export class PronunciationGenerator { return container; } + /** + * @param {string[]} morae + * @param {number} downstepPosition + * @returns {SVGSVGElement} + */ createPronunciationGraph(morae, downstepPosition) { const jp = this._japaneseUtil; const ii = morae.length; @@ -145,12 +161,16 @@ export class PronunciationGenerator { return svg; } + /** + * @param {number} downstepPosition + * @returns {HTMLSpanElement} + */ createPronunciationDownstepPosition(downstepPosition) { - downstepPosition = `${downstepPosition}`; + const downstepPositionString = `${downstepPosition}`; const n1 = document.createElement('span'); n1.className = 'pronunciation-downstep-notation'; - n1.dataset.downstepPosition = downstepPosition; + n1.dataset.downstepPosition = downstepPositionString; let n2 = document.createElement('span'); n2.className = 'pronunciation-downstep-notation-prefix'; @@ -159,7 +179,7 @@ export class PronunciationGenerator { n2 = document.createElement('span'); n2.className = 'pronunciation-downstep-notation-number'; - n2.textContent = downstepPosition; + n2.textContent = downstepPositionString; n1.appendChild(n2); n2 = document.createElement('span'); @@ -172,15 +192,33 @@ export class PronunciationGenerator { // Private + /** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ _addGraphDot(container, svgns, x, y) { container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot', x, y, '15')); } + /** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ _addGraphDotDownstep(container, svgns, x, y) { container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep1', x, y, '15')); container.appendChild(this._createGraphCircle(svgns, 'pronunciation-graph-dot-downstep2', x, y, '5')); } + /** + * @param {Element} container + * @param {string} svgns + * @param {number} x + * @param {number} y + */ _addGraphTriangle(container, svgns, x, y) { const node = document.createElementNS(svgns, 'path'); node.setAttribute('class', 'pronunciation-graph-triangle'); @@ -189,6 +227,14 @@ export class PronunciationGenerator { container.appendChild(node); } + /** + * @param {string} svgns + * @param {string} className + * @param {number} x + * @param {number} y + * @param {string} radius + * @returns {Element} + */ _createGraphCircle(svgns, className, x, y, radius) { const node = document.createElementNS(svgns, 'circle'); node.setAttribute('class', className); diff --git a/ext/js/display/sandbox/structured-content-generator.js b/ext/js/display/sandbox/structured-content-generator.js index 227892d6..5a91b01c 100644 --- a/ext/js/display/sandbox/structured-content-generator.js +++ b/ext/js/display/sandbox/structured-content-generator.js @@ -17,28 +17,51 @@ */ export class StructuredContentGenerator { + /** + * @param {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} contentManager + * @param {import('../../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {Document} document + */ constructor(contentManager, japaneseUtil, document) { + /** @type {import('../../display/display-content-manager.js').DisplayContentManager|import('../../templates/sandbox/anki-template-renderer-content-manager.js').AnkiTemplateRendererContentManager} */ this._contentManager = contentManager; + /** @type {import('../../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {Document} */ this._document = document; } + /** + * @param {HTMLElement} node + * @param {import('structured-content').Content} content + * @param {string} dictionary + */ appendStructuredContent(node, content, dictionary) { node.classList.add('structured-content'); this._appendStructuredContent(node, content, dictionary, null); } + /** + * @param {import('structured-content').Content} content + * @param {string} dictionary + * @returns {HTMLElement} + */ createStructuredContent(content, dictionary) { const node = this._createElement('span', 'structured-content'); this._appendStructuredContent(node, content, dictionary, null); return node; } + /** + * @param {import('structured-content').ImageElementBase} data + * @param {string} dictionary + * @returns {HTMLAnchorElement} + */ createDefinitionImage(data, dictionary) { const { path, - width, - height, + width = 100, + height = 100, preferredWidth, preferredHeight, title, @@ -65,7 +88,7 @@ export class StructuredContentGenerator { (hasPreferredHeight ? preferredHeight / invAspectRatio : width) ); - const node = this._createElement('a', 'gloss-image-link'); + const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-image-link')); node.target = '_blank'; node.rel = 'noreferrer noopener'; @@ -78,7 +101,7 @@ export class StructuredContentGenerator { const imageBackground = this._createElement('span', 'gloss-image-background'); imageContainer.appendChild(imageBackground); - const image = this._createElement('img', 'gloss-image'); + const image = /** @type {HTMLImageElement} */ (this._createElement('img', 'gloss-image')); image.alt = ''; imageContainer.appendChild(image); @@ -126,6 +149,12 @@ export class StructuredContentGenerator { // Private + /** + * @param {HTMLElement} container + * @param {import('structured-content').Content|undefined} content + * @param {string} dictionary + * @param {?string} language + */ _appendStructuredContent(container, content, dictionary, language) { if (typeof content === 'string') { if (content.length > 0) { @@ -151,16 +180,29 @@ export class StructuredContentGenerator { } } + /** + * @param {string} tagName + * @param {string} className + * @returns {HTMLElement} + */ _createElement(tagName, className) { const node = this._document.createElement(tagName); node.className = className; return node; } + /** + * @param {string} data + * @returns {Text} + */ _createTextNode(data) { return this._document.createTextNode(data); } + /** + * @param {HTMLElement} element + * @param {import('structured-content').Data} data + */ _setElementDataset(element, data) { for (let [key, value] of Object.entries(data)) { if (key.length > 0) { @@ -175,6 +217,13 @@ export class StructuredContentGenerator { } } + /** + * @param {HTMLAnchorElement} node + * @param {HTMLImageElement} image + * @param {HTMLElement} imageBackground + * @param {?string} url + * @param {boolean} unloaded + */ _setImageData(node, image, imageBackground, url, unloaded) { if (url !== null) { image.src = url; @@ -189,6 +238,12 @@ export class StructuredContentGenerator { } } + /** + * @param {import('structured-content').Element} content + * @param {string} dictionary + * @param {?string} language + * @returns {?HTMLElement} + */ _createStructuredContentGenericElement(content, dictionary, language) { const {tag} = content; switch (tag) { @@ -222,6 +277,13 @@ export class StructuredContentGenerator { return null; } + /** + * @param {string} tag + * @param {import('structured-content').UnstyledElement} content + * @param {string} dictionary + * @param {?string} language + * @returns {HTMLElement} + */ _createStructuredContentTableElement(tag, content, dictionary, language) { const container = this._createElement('div', 'gloss-sc-table-container'); const table = this._createStructuredContentElement(tag, content, dictionary, language, 'table', true, false); @@ -229,6 +291,16 @@ export class StructuredContentGenerator { return container; } + /** + * @param {string} tag + * @param {import('structured-content').StyledElement|import('structured-content').UnstyledElement|import('structured-content').TableElement|import('structured-content').LineBreak} content + * @param {string} dictionary + * @param {?string} language + * @param {'simple'|'table'|'table-cell'} type + * @param {boolean} hasChildren + * @param {boolean} hasStyle + * @returns {HTMLElement} + */ _createStructuredContentElement(tag, content, dictionary, language, type, hasChildren, hasStyle) { const node = this._createElement(tag, `gloss-sc-${tag}`); const {data, lang} = content; @@ -240,14 +312,15 @@ export class StructuredContentGenerator { switch (type) { case 'table-cell': { - const {colSpan, rowSpan} = content; - if (typeof colSpan === 'number') { node.colSpan = colSpan; } - if (typeof rowSpan === 'number') { node.rowSpan = rowSpan; } + const cell = /** @type {HTMLTableCellElement} */ (node); + const {colSpan, rowSpan} = /** @type {import('structured-content').TableElement} */ (content); + if (typeof colSpan === 'number') { cell.colSpan = colSpan; } + if (typeof rowSpan === 'number') { cell.rowSpan = rowSpan; } } break; } if (hasStyle) { - const {style} = content; + const {style} = /** @type {import('structured-content').StyledElement} */ (content); if (typeof style === 'object' && style !== null) { this._setStructuredContentElementStyle(node, style); } @@ -258,6 +331,10 @@ export class StructuredContentGenerator { return node; } + /** + * @param {HTMLElement} node + * @param {import('structured-content').StructuredContentStyle} contentStyle + */ _setStructuredContentElementStyle(node, contentStyle) { const {style} = node; const { @@ -290,6 +367,12 @@ export class StructuredContentGenerator { if (typeof listStyleType === 'string') { style.listStyleType = listStyleType; } } + /** + * @param {import('structured-content').LinkElement} content + * @param {string} dictionary + * @param {?string} language + * @returns {HTMLAnchorElement} + */ _createLinkElement(content, dictionary, language) { let {href} = content; const internal = href.startsWith('?'); @@ -297,7 +380,7 @@ export class StructuredContentGenerator { href = `${location.protocol}//${location.host}/search.html${href.length > 1 ? href : ''}`; } - const node = this._createElement('a', 'gloss-link'); + const node = /** @type {HTMLAnchorElement} */ (this._createElement('a', 'gloss-link')); node.dataset.external = `${!internal}`; const text = this._createElement('span', 'gloss-link-text'); diff --git a/ext/js/display/search-action-popup-controller.js b/ext/js/display/search-action-popup-controller.js index e8fb9f1b..3a2057a1 100644 --- a/ext/js/display/search-action-popup-controller.js +++ b/ext/js/display/search-action-popup-controller.js @@ -17,10 +17,15 @@ */ export class SearchActionPopupController { + /** + * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + */ constructor(searchPersistentStateController) { + /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */ this._searchPersistentStateController = searchPersistentStateController; } + /** */ prepare() { const searchParams = new URLSearchParams(location.search); if (searchParams.get('action-popup') !== 'true') { return; } diff --git a/ext/js/display/search-display-controller.js b/ext/js/display/search-display-controller.js index e31bd239..2778c4cd 100644 --- a/ext/js/display/search-display-controller.js +++ b/ext/js/display/search-display-controller.js @@ -22,34 +22,63 @@ import {EventListenerCollection, invokeMessageHandler} from '../core.js'; import {yomitan} from '../yomitan.js'; export class SearchDisplayController { + /** + * @param {number|undefined} tabId + * @param {number|undefined} frameId + * @param {import('./display.js').Display} display + * @param {import('./display-audio.js').DisplayAudio} displayAudio + * @param {import('../language/sandbox/japanese-util.js').JapaneseUtil} japaneseUtil + * @param {import('./search-persistent-state-controller.js').SearchPersistentStateController} searchPersistentStateController + */ constructor(tabId, frameId, display, displayAudio, japaneseUtil, searchPersistentStateController) { + /** @type {number|undefined} */ this._tabId = tabId; + /** @type {number|undefined} */ this._frameId = frameId; + /** @type {import('./display.js').Display} */ this._display = display; + /** @type {import('./display-audio.js').DisplayAudio} */ this._displayAudio = displayAudio; + /** @type {import('./search-persistent-state-controller.js').SearchPersistentStateController} */ this._searchPersistentStateController = searchPersistentStateController; - this._searchButton = document.querySelector('#search-button'); - this._searchBackButton = document.querySelector('#search-back-button'); - this._queryInput = document.querySelector('#search-textbox'); - this._introElement = document.querySelector('#intro'); - this._clipboardMonitorEnableCheckbox = document.querySelector('#clipboard-monitor-enable'); - this._wanakanaEnableCheckbox = document.querySelector('#wanakana-enable'); + /** @type {HTMLButtonElement} */ + this._searchButton = /** @type {HTMLButtonElement} */ (document.querySelector('#search-button')); + /** @type {HTMLButtonElement} */ + this._searchBackButton = /** @type {HTMLButtonElement} */ (document.querySelector('#search-back-button')); + /** @type {HTMLTextAreaElement} */ + this._queryInput = /** @type {HTMLTextAreaElement} */ (document.querySelector('#search-textbox')); + /** @type {HTMLElement} */ + this._introElement = /** @type {HTMLElement} */ (document.querySelector('#intro')); + /** @type {HTMLInputElement} */ + this._clipboardMonitorEnableCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#clipboard-monitor-enable')); + /** @type {HTMLInputElement} */ + this._wanakanaEnableCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#wanakana-enable')); + /** @type {EventListenerCollection} */ this._queryInputEvents = new EventListenerCollection(); + /** @type {boolean} */ this._queryInputEventsSetup = false; + /** @type {boolean} */ this._wanakanaEnabled = false; + /** @type {boolean} */ this._wanakanaBound = false; + /** @type {boolean} */ this._introVisible = true; + /** @type {?import('core').Timeout} */ this._introAnimationTimer = null; + /** @type {boolean} */ this._clipboardMonitorEnabled = false; + /** @type {ClipboardMonitor} */ this._clipboardMonitor = new ClipboardMonitor({ japaneseUtil, clipboardReader: { getText: yomitan.api.clipboardGet.bind(yomitan.api) } }); + /** @type {import('core').MessageHandlerMap} */ this._messageHandlers = new Map(); } + /** */ async prepare() { await this._display.updateOptions(); @@ -84,15 +113,22 @@ export class SearchDisplayController { this._clipboardMonitorEnableCheckbox.addEventListener('change', this._onClipboardMonitorEnableChange.bind(this)); this._display.hotkeyHandler.on('keydownNonHotkey', this._onKeyDown.bind(this)); - this._onDisplayOptionsUpdated({options: this._display.getOptions()}); + const displayOptions = this._display.getOptions(); + if (displayOptions !== null) { + this._onDisplayOptionsUpdated({options: displayOptions}); + } } + /** + * @param {import('display').SearchMode} mode + */ setMode(mode) { - this._setMode(mode, true); + this._searchPersistentStateController.mode = mode; } // Actions + /** */ _onActionFocusSearchBox() { if (this._queryInput === null) { return; } this._queryInput.focus(); @@ -101,22 +137,37 @@ export class SearchDisplayController { // Messages + /** + * @param {{mode: import('display').SearchMode}} details + */ _onMessageSetMode({mode}) { - this._searchPersistentStateController.mode = mode; + this.setMode(mode); } + /** + * @returns {import('display').SearchMode} + */ _onMessageGetMode() { return this._searchPersistentStateController.mode; } // Private + /** + * @param {{action: string, params?: import('core').SerializableObject}} message + * @param {chrome.runtime.MessageSender} sender + * @param {(response?: unknown) => void} callback + * @returns {boolean} + */ _onMessage({action, params}, sender, callback) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } return invokeMessageHandler(messageHandler, params, callback, sender); } + /** + * @param {KeyboardEvent} e + */ _onKeyDown(e) { const {activeElement} = document; if ( @@ -132,6 +183,7 @@ export class SearchDisplayController { } } + /** */ async _onOptionsUpdated() { await this._display.updateOptions(); const query = this._queryInput.value; @@ -140,15 +192,21 @@ export class SearchDisplayController { } } + /** + * @param {import('display').OptionsUpdatedEvent} details + */ _onDisplayOptionsUpdated({options}) { this._clipboardMonitorEnabled = options.clipboard.enableSearchPageMonitor; this._updateClipboardMonitorEnabled(); - const enableWanakana = !!this._display.getOptions().general.enableWanakana; + const enableWanakana = !!options.general.enableWanakana; this._wanakanaEnableCheckbox.checked = enableWanakana; this._setWanakanaEnabled(enableWanakana); } + /** + * @param {import('display').ContentUpdateStartEvent} details + */ _onContentUpdateStart({type, query}) { let animate = false; let valid = false; @@ -182,38 +240,54 @@ export class SearchDisplayController { this._setIntroVisible(!valid, animate); } + /** */ _onSearchInput() { this._updateSearchHeight(false); } + /** + * @param {KeyboardEvent} e + */ _onSearchKeydown(e) { if (e.isComposing) { return; } const {code} = e; if (!((code === 'Enter' || code === 'NumpadEnter') && !e.shiftKey)) { return; } // Search + const element = /** @type {HTMLElement} */ (e.currentTarget); e.preventDefault(); e.stopImmediatePropagation(); - this._display.blurElement(e.currentTarget); + this._display.blurElement(element); this._search(true, 'new', true, null); } + /** + * @param {MouseEvent} e + */ _onSearch(e) { e.preventDefault(); this._search(true, 'new', true, null); } + /** */ _onSearchBackButtonClick() { this._display.history.back(); } + /** */ _onCopy() { // ignore copy from search page - this._clipboardMonitor.setPreviousText(window.getSelection().toString().trim()); + const selection = window.getSelection(); + this._clipboardMonitor.setPreviousText(selection !== null ? selection.toString().trim() : ''); } + /** + * @param {{text: string, animate?: boolean}} details + */ _onExternalSearchUpdate({text, animate=true}) { - const {clipboard: {autoSearchContent, maximumSearchLength}} = this._display.getOptions(); + const options = this._display.getOptions(); + if (options === null) { return; } + const {clipboard: {autoSearchContent, maximumSearchLength}} = options; if (text.length > maximumSearchLength) { text = text.substring(0, maximumSearchLength); } @@ -222,27 +296,41 @@ export class SearchDisplayController { this._search(animate, 'clear', autoSearchContent, ['clipboard']); } + /** + * @param {Event} e + */ _onWanakanaEnableChange(e) { - const value = e.target.checked; + const element = /** @type {HTMLInputElement} */ (e.target); + const value = element.checked; this._setWanakanaEnabled(value); - yomitan.api.modifySettings([{ + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'general.enableWanakana', value, scope: 'profile', optionsContext: this._display.getOptionsContext() - }], 'search'); + }; + yomitan.api.modifySettings([modification], 'search'); } + /** + * @param {Event} e + */ _onClipboardMonitorEnableChange(e) { - const enabled = e.target.checked; + const element = /** @type {HTMLInputElement} */ (e.target); + const enabled = element.checked; this._setClipboardMonitorEnabled(enabled); } + /** */ _onModeChange() { this._updateClipboardMonitorEnabled(); } + /** + * @param {boolean} enabled + */ _setWanakanaEnabled(enabled) { if (this._queryInputEventsSetup && this._wanakanaEnabled === enabled) { return; } @@ -267,6 +355,10 @@ export class SearchDisplayController { this._queryInputEventsSetup = true; } + /** + * @param {boolean} visible + * @param {boolean} animate + */ _setIntroVisible(visible, animate) { if (this._introVisible === visible) { return; @@ -290,6 +382,9 @@ export class SearchDisplayController { } } + /** + * @param {boolean} animate + */ _showIntro(animate) { if (animate) { const duration = 0.4; @@ -310,6 +405,9 @@ export class SearchDisplayController { } } + /** + * @param {boolean} animate + */ _hideIntro(animate) { if (animate) { const duration = 0.4; @@ -323,6 +421,9 @@ export class SearchDisplayController { this._introElement.style.height = '0'; } + /** + * @param {boolean} value + */ async _setClipboardMonitorEnabled(value) { let modify = true; if (value) { @@ -335,15 +436,18 @@ export class SearchDisplayController { if (!modify) { return; } - await yomitan.api.modifySettings([{ + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { action: 'set', path: 'clipboard.enableSearchPageMonitor', value, scope: 'profile', optionsContext: this._display.getOptionsContext() - }], 'search'); + }; + await yomitan.api.modifySettings([modification], 'search'); } + /** */ _updateClipboardMonitorEnabled() { const enabled = this._clipboardMonitorEnabled; this._clipboardMonitorEnableCheckbox.checked = enabled; @@ -354,6 +458,9 @@ export class SearchDisplayController { } } + /** + * @returns {boolean} + */ _canEnableClipboardMonitor() { switch (this._searchPersistentStateController.mode) { case 'popup': @@ -364,6 +471,10 @@ export class SearchDisplayController { } } + /** + * @param {string[]} permissions + * @returns {Promise<boolean>} + */ _requestPermissions(permissions) { return new Promise((resolve) => { chrome.permissions.request( @@ -376,15 +487,23 @@ export class SearchDisplayController { }); } + /** + * @param {boolean} animate + * @param {import('display').HistoryMode} historyMode + * @param {boolean} lookup + * @param {?import('settings').OptionsContextFlag[]} flags + */ _search(animate, historyMode, lookup, flags) { const query = this._queryInput.value; const depth = this._display.depth; const url = window.location.href; const documentTitle = document.title; + /** @type {import('settings').OptionsContext} */ const optionsContext = {depth, url}; if (flags !== null) { optionsContext.flags = flags; } + /** @type {import('display').ContentDetails} */ const details = { focus: false, historyMode, @@ -399,7 +518,7 @@ export class SearchDisplayController { documentTitle }, content: { - dictionaryEntries: null, + dictionaryEntries: void 0, animate, contentOrigin: { tabId: this._tabId, @@ -411,6 +530,9 @@ export class SearchDisplayController { this._display.setContent(details); } + /** + * @param {boolean} shrink + */ _updateSearchHeight(shrink) { const node = this._queryInput; if (shrink) { @@ -423,12 +545,19 @@ export class SearchDisplayController { } } + /** + * @param {import('core').MessageHandlerArray} handlers + */ _registerMessageHandlers(handlers) { for (const [name, handlerInfo] of handlers) { this._messageHandlers.set(name, handlerInfo); } } + /** + * @param {?Element} element + * @returns {boolean} + */ _isElementInput(element) { if (element === null) { return false; } switch (element.tagName.toLowerCase()) { @@ -438,7 +567,7 @@ export class SearchDisplayController { case 'select': return true; } - if (element.isContentEditable) { return true; } + if (element instanceof HTMLElement && element.isContentEditable) { return true; } return false; } } diff --git a/ext/js/display/search-persistent-state-controller.js b/ext/js/display/search-persistent-state-controller.js index 60155143..d92ddf68 100644 --- a/ext/js/display/search-persistent-state-controller.js +++ b/ext/js/display/search-persistent-state-controller.js @@ -18,12 +18,17 @@ import {EventDispatcher} from '../core.js'; +/** + * @augments EventDispatcher<import('display').SearchPersistentStateControllerEventType> + */ export class SearchPersistentStateController extends EventDispatcher { constructor() { super(); + /** @type {import('display').SearchMode} */ this._mode = null; } + /** @type {import('display').SearchMode} */ get mode() { return this._mode; } @@ -32,12 +37,14 @@ export class SearchPersistentStateController extends EventDispatcher { this._setMode(value, true); } + /** */ prepare() { this._updateMode(); } // Private + /** */ _updateMode() { let mode = null; try { @@ -45,9 +52,13 @@ export class SearchPersistentStateController extends EventDispatcher { } catch (e) { // Browsers can throw a SecurityError when cookie blocking is enabled. } - this._setMode(mode, false); + this._setMode(this._normalizeMode(mode), false); } + /** + * @param {import('display').SearchMode} mode + * @param {boolean} save + */ _setMode(mode, save) { if (mode === this._mode) { return; } if (save) { @@ -65,4 +76,18 @@ export class SearchPersistentStateController extends EventDispatcher { document.documentElement.dataset.searchMode = (mode !== null ? mode : ''); this.trigger('modeChange', {mode}); } + + /** + * @param {?string} mode + * @returns {import('display').SearchMode} + */ + _normalizeMode(mode) { + switch (mode) { + case 'popup': + case 'action-popup': + return mode; + default: + return null; + } + } } diff --git a/ext/js/dom/document-focus-controller.js b/ext/js/dom/document-focus-controller.js index 501d1fad..32ea2ce8 100644 --- a/ext/js/dom/document-focus-controller.js +++ b/ext/js/dom/document-focus-controller.js @@ -29,7 +29,9 @@ export class DocumentFocusController { * should be automatically focused on prepare. */ constructor(autofocusElementSelector=null) { + /** @type {?HTMLElement} */ this._autofocusElement = (autofocusElementSelector !== null ? document.querySelector(autofocusElementSelector) : null); + /** @type {?HTMLElement} */ this._contentScrollFocusElement = document.querySelector('#content-scroll-focus'); } @@ -46,7 +48,7 @@ export class DocumentFocusController { /** * Removes focus from a given element. - * @param {Element} element The element to remove focus from. + * @param {HTMLElement} element The element to remove focus from. */ blurElement(element) { if (document.activeElement !== element) { return; } @@ -56,10 +58,14 @@ export class DocumentFocusController { // Private + /** */ _onWindowFocus() { this._updateFocusedElement(false); } + /** + * @param {boolean} force + */ _updateFocusedElement(force) { const target = this._contentScrollFocusElement; if (target === null) { return; } @@ -73,6 +79,7 @@ export class DocumentFocusController { ) { // Get selection const selection = window.getSelection(); + if (selection === null) { return; } const selectionRanges1 = this._getSelectionRanges(selection); // Note: This function will cause any selected text to be deselected on Firefox. @@ -86,6 +93,10 @@ export class DocumentFocusController { } } + /** + * @param {Selection} selection + * @returns {Range[]} + */ _getSelectionRanges(selection) { const ranges = []; for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { @@ -94,6 +105,10 @@ export class DocumentFocusController { return ranges; } + /** + * @param {Selection} selection + * @param {Range[]} ranges + */ _setSelectionRanges(selection, ranges) { selection.removeAllRanges(); for (const range of ranges) { @@ -101,6 +116,11 @@ export class DocumentFocusController { } } + /** + * @param {Range[]} ranges1 + * @param {Range[]} ranges2 + * @returns {boolean} + */ _areRangesSame(ranges1, ranges2) { const ii = ranges1.length; if (ii !== ranges2.length) { diff --git a/ext/js/dom/document-util.js b/ext/js/dom/document-util.js index d379192c..b2aa8f81 100644 --- a/ext/js/dom/document-util.js +++ b/ext/js/dom/document-util.js @@ -16,7 +16,6 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventListenerCollection} from '../core.js'; import {DOMTextScanner} from './dom-text-scanner.js'; import {TextSourceElement} from './text-source-element.js'; import {TextSourceRange} from './text-source-range.js'; @@ -26,29 +25,11 @@ import {TextSourceRange} from './text-source-range.js'; */ export class DocumentUtil { /** - * Options to configure how element detection is performed. - * @typedef {object} GetRangeFromPointOptions - * @property {boolean} deepContentScan Whether or deep content scanning should be performed. When deep content scanning is enabled, - * some transparent overlay elements will be ignored when looking for the element at the input position. - * @property {boolean} normalizeCssZoom Whether or not zoom coordinates should be normalized. - */ - - /** - * Scans the document for text or elements with text information at the given coordinate. - * Coordinates are provided in [client space](https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems). - * @callback GetRangeFromPointHandler - * @param {number} x The x coordinate to search at. - * @param {number} y The y coordinate to search at. - * @param {GetRangeFromPointOptions} options Options to configure how element detection is performed. - * @returns {?TextSourceRange|TextSourceElement} A range for the hovered text or element, or `null` if no applicable content was found. - */ - - /** * Scans the document for text or elements with text information at the given coordinate. * Coordinates are provided in [client space](https://developer.mozilla.org/en-US/docs/Web/CSS/CSSOM_View/Coordinate_systems). * @param {number} x The x coordinate to search at. * @param {number} y The y coordinate to search at. - * @param {GetRangeFromPointOptions} options Options to configure how element detection is performed. + * @param {import('document-util').GetRangeFromPointOptions} options Options to configure how element detection is performed. * @returns {?TextSourceRange|TextSourceElement} A range for the hovered text or element, or `null` if no applicable content was found. */ static getRangeFromPoint(x, y, options) { @@ -60,8 +41,11 @@ export class DocumentUtil { const {deepContentScan, normalizeCssZoom} = options; const elements = this._getElementsFromPoint(x, y, deepContentScan); + /** @type {?HTMLDivElement} */ let imposter = null; + /** @type {?HTMLDivElement} */ let imposterContainer = null; + /** @type {?Element} */ let imposterSourceElement = null; if (elements.length > 0) { const element = elements[0]; @@ -71,14 +55,14 @@ export class DocumentUtil { case 'SELECT': return TextSourceElement.create(element); case 'INPUT': - if (element.type === 'text') { + if (/** @type {HTMLInputElement} */ (element).type === 'text') { imposterSourceElement = element; - [imposter, imposterContainer] = this._createImposter(element, false); + [imposter, imposterContainer] = this._createImposter(/** @type {HTMLInputElement} */ (element), false); } break; case 'TEXTAREA': imposterSourceElement = element; - [imposter, imposterContainer] = this._createImposter(element, true); + [imposter, imposterContainer] = this._createImposter(/** @type {HTMLTextAreaElement} */ (element), true); break; } } @@ -86,14 +70,17 @@ export class DocumentUtil { const range = this._caretRangeFromPointExt(x, y, deepContentScan ? elements : [], normalizeCssZoom); if (range !== null) { if (imposter !== null) { - this._setImposterStyle(imposterContainer.style, 'z-index', '-2147483646'); + this._setImposterStyle(/** @type {HTMLDivElement} */ (imposterContainer).style, 'z-index', '-2147483646'); this._setImposterStyle(imposter.style, 'pointer-events', 'none'); - return TextSourceRange.createFromImposter(range, imposterContainer, imposterSourceElement); + return TextSourceRange.createFromImposter(range, /** @type {HTMLDivElement} */ (imposterContainer), /** @type {HTMLElement} */ (imposterSourceElement)); } return TextSourceRange.create(range); } else { if (imposterContainer !== null) { - imposterContainer.parentNode.removeChild(imposterContainer); + const {parentNode} = imposterContainer; + if (parentNode !== null) { + parentNode.removeChild(imposterContainer); + } } return null; } @@ -101,7 +88,7 @@ export class DocumentUtil { /** * Registers a custom handler for scanning for text or elements at the input position. - * @param {GetRangeFromPointHandler} handler The handler callback which will be invoked when calling `getRangeFromPoint`. + * @param {import('document-util').GetRangeFromPointHandler} handler The handler callback which will be invoked when calling `getRangeFromPoint`. */ static registerGetRangeFromPointHandler(handler) { this._getRangeFromPointHandlers.push(handler); @@ -129,7 +116,7 @@ export class DocumentUtil { * ```js * new Map([ [character: string, [otherCharacter: string, includeCharacterAtEnd: boolean]], ... ]) * ``` - * @returns {{sentence: string, offset: number}} The sentence and the offset to the original source. + * @returns {{text: string, offset: number}} The sentence and the offset to the original source. */ static extractSentence(source, layoutAwareScan, extent, terminateAtNewlines, terminatorMap, forwardQuoteMap, backwardQuoteMap) { // Scan text @@ -218,11 +205,12 @@ export class DocumentUtil { /** * Computes the scaling adjustment that is necessary for client space coordinates based on the * CSS zoom level. - * @param {Node} node A node in the document. + * @param {?Node} node A node in the document. * @returns {number} The scaling factor. */ static computeZoomScale(node) { if (this._cssZoomSupported === null) { + // @ts-expect-error - zoom is a non-standard property that exists in Chromium-based browsers this._cssZoomSupported = (typeof document.createElement('div').style.zoom === 'string'); } if (!this._cssZoomSupported) { return 1; } @@ -237,7 +225,7 @@ export class DocumentUtil { for (; node !== null && node !== documentElement; node = node.parentNode) { const {nodeType} = node; if (nodeType === DOCUMENT_FRAGMENT_NODE) { - const {host} = node; + const {host} = /** @type {ShadowRoot} */ (node); if (typeof host !== 'undefined') { node = host; } @@ -245,9 +233,9 @@ export class DocumentUtil { } else if (nodeType !== ELEMENT_NODE) { continue; } - let {zoom} = getComputedStyle(node); - if (typeof zoom !== 'string') { continue; } - zoom = Number.parseFloat(zoom); + const zoomString = getComputedStyle(/** @type {HTMLElement} */ (node)).getPropertyValue('zoom'); + if (typeof zoomString !== 'string' || zoomString.length === 0) { continue; } + const zoom = Number.parseFloat(zoomString); if (!Number.isFinite(zoom) || zoom === 0) { continue; } scale *= zoom; } @@ -267,13 +255,13 @@ export class DocumentUtil { /** * Converts multiple rects based on the CSS zoom scaling for a given node. - * @param {DOMRect[]} rects The rects to convert. + * @param {DOMRect[]|DOMRectList} rects The rects to convert. * @param {Node} node The node to compute the zoom from. * @returns {DOMRect[]} The updated rects, or the same rects array if no change is needed. */ static convertMultipleRectZoomCoordinates(rects, node) { const scale = this.computeZoomScale(node); - if (scale === 1) { return rects; } + if (scale === 1) { return [...rects]; } const results = []; for (const rect of rects) { results.push(new DOMRect(rect.left * scale, rect.top * scale, rect.width * scale, rect.height * scale)); @@ -299,7 +287,7 @@ export class DocumentUtil { * Checks whether a given point is contained within any rect in a list. * @param {number} x The horizontal coordinate. * @param {number} y The vertical coordinate. - * @param {DOMRect[]} rects The rect to check. + * @param {DOMRect[]|DOMRectList} rects The rect to check. * @returns {boolean} `true` if the point is inside any of the rects, `false` otherwise. */ static isPointInAnyRect(x, y, rects) { @@ -331,9 +319,10 @@ export class DocumentUtil { /** * Gets an array of the active modifier keys. * @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check. - * @returns {string[]} An array of modifiers. + * @returns {import('input').ModifierKey[]} An array of modifiers. */ static getActiveModifiers(event) { + /** @type {import('input').ModifierKey[]} */ const modifiers = []; if (event.altKey) { modifiers.push('alt'); } if (event.ctrlKey) { modifiers.push('ctrl'); } @@ -345,20 +334,24 @@ export class DocumentUtil { /** * Gets an array of the active modifier keys and buttons. * @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check. - * @returns {string[]} An array of modifiers and buttons. + * @returns {import('input').Modifier[]} An array of modifiers and buttons. */ static getActiveModifiersAndButtons(event) { + /** @type {import('input').Modifier[]} */ const modifiers = this.getActiveModifiers(event); - this._getActiveButtons(event, modifiers); + if (event instanceof MouseEvent) { + this._getActiveButtons(event, modifiers); + } return modifiers; } /** * Gets an array of the active buttons. - * @param {KeyboardEvent|MouseEvent|TouchEvent} event The event to check. - * @returns {string[]} An array of modifiers and buttons. + * @param {MouseEvent} event The event to check. + * @returns {import('input').ModifierMouseButton[]} An array of modifiers and buttons. */ static getActiveButtons(event) { + /** @type {import('input').ModifierMouseButton[]} */ const buttons = []; this._getActiveButtons(event, buttons); return buttons; @@ -366,8 +359,8 @@ export class DocumentUtil { /** * Adds a fullscreen change event listener. This function handles all of the browser-specific variants. - * @param {Function} onFullscreenChanged The event callback. - * @param {?EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to. + * @param {EventListener} onFullscreenChanged The event callback. + * @param {?import('../core.js').EventListenerCollection} eventListenerCollection An optional `EventListenerCollection` to add the registration to. */ static addFullscreenChangeEventListener(onFullscreenChanged, eventListenerCollection=null) { const target = document; @@ -394,8 +387,11 @@ export class DocumentUtil { static getFullscreenElement() { return ( document.fullscreenElement || + // @ts-expect-error - vendor prefix document.msFullscreenElement || + // @ts-expect-error - vendor prefix document.mozFullScreenElement || + // @ts-expect-error - vendor prefix document.webkitFullscreenElement || null ); @@ -409,7 +405,7 @@ export class DocumentUtil { static getNodesInRange(range) { const end = range.endContainer; const nodes = []; - for (let node = range.startContainer; node !== null; node = this.getNextNode(node)) { + for (let node = /** @type {?Node} */ (range.startContainer); node !== null; node = this.getNextNode(node)) { nodes.push(node); if (node === end) { break; } } @@ -422,7 +418,7 @@ export class DocumentUtil { * @returns {?Node} The next node, or `null` if there is no next node. */ static getNextNode(node) { - let next = node.firstChild; + let next = /** @type {?Node} */ (node.firstChild); if (next === null) { while (true) { next = node.nextSibling; @@ -445,10 +441,14 @@ export class DocumentUtil { */ static anyNodeMatchesSelector(nodes, selector) { const ELEMENT_NODE = Node.ELEMENT_NODE; - for (let node of nodes) { - for (; node !== null; node = node.parentNode) { - if (node.nodeType !== ELEMENT_NODE) { continue; } - if (node.matches(selector)) { return true; } + // This is a rather ugly way of getting the "node" variable to be a nullable + for (let node of /** @type {(?Node)[]} */ (nodes)) { + while (node !== null) { + if (node.nodeType !== ELEMENT_NODE) { + node = node.parentNode; + continue; + } + if (/** @type {HTMLElement} */ (node).matches(selector)) { return true; } break; } } @@ -463,10 +463,11 @@ export class DocumentUtil { */ static everyNodeMatchesSelector(nodes, selector) { const ELEMENT_NODE = Node.ELEMENT_NODE; - for (let node of nodes) { + // This is a rather ugly way of getting the "node" variable to be a nullable + for (let node of /** @type {(?Node)[]} */ (nodes)) { while (true) { if (node === null) { return false; } - if (node.nodeType === ELEMENT_NODE && node.matches(selector)) { break; } + if (node.nodeType === ELEMENT_NODE && /** @type {HTMLElement} */ (node).matches(selector)) { break; } node = node.parentNode; } } @@ -497,7 +498,7 @@ export class DocumentUtil { case 'SELECT': return true; default: - return element.isContentEditable; + return element instanceof HTMLElement && element.isContentEditable; } } @@ -506,7 +507,7 @@ export class DocumentUtil { * @param {DOMRect[]} rects The DOMRects to offset. * @param {number} x The horizontal offset amount. * @param {number} y The vertical offset amount. - * @returns {DOMRect} The DOMRects with the offset applied. + * @returns {DOMRect[]} The DOMRects with the offset applied. */ static offsetDOMRects(rects, x, y) { const results = []; @@ -519,8 +520,8 @@ export class DocumentUtil { /** * Gets the parent writing mode of an element. * See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode. - * @param {Element} element The HTML element to check. - * @returns {string} The writing mode. + * @param {?Element} element The HTML element to check. + * @returns {import('document-util').NormalizedWritingMode} The writing mode. */ static getElementWritingMode(element) { if (element !== null) { @@ -536,52 +537,95 @@ export class DocumentUtil { * Normalizes a CSS writing mode value by converting non-standard and deprecated values * into their corresponding standard vaules. * @param {string} writingMode The writing mode to normalize. - * @returns {string} The normalized writing mode. + * @returns {import('document-util').NormalizedWritingMode} The normalized writing mode. */ static normalizeWritingMode(writingMode) { switch (writingMode) { - case 'lr': - case 'lr-tb': - case 'rl': - return 'horizontal-tb'; case 'tb': return 'vertical-lr'; case 'tb-rl': return 'vertical-rl'; - default: + case 'horizontal-tb': + case 'vertical-rl': + case 'vertical-lr': + case 'sideways-rl': + case 'sideways-lr': return writingMode; + default: // 'lr', 'lr-tb', 'rl' + return 'horizontal-tb'; } } /** * Converts a value from an element to a number. - * @param {string} value A string representation of a number. - * @param {object} constraints An object which might contain `min`, `max`, and `step` fields which are used to constrain the value. + * @param {string} valueString A string representation of a number. + * @param {import('document-util').ToNumberConstraints} constraints An object which might contain `min`, `max`, and `step` fields which are used to constrain the value. * @returns {number} The parsed and constrained number. */ - static convertElementValueToNumber(value, constraints) { - value = parseFloat(value); + static convertElementValueToNumber(valueString, constraints) { + let value = Number.parseFloat(valueString); if (!Number.isFinite(value)) { value = 0; } - let {min, max, step} = constraints; - min = this._convertToNumberOrNull(min); - max = this._convertToNumberOrNull(max); - step = this._convertToNumberOrNull(step); + const min = this._convertToNumberOrNull(constraints.min); + const max = this._convertToNumberOrNull(constraints.max); + const step = this._convertToNumberOrNull(constraints.step); if (typeof min === 'number') { value = Math.max(value, min); } if (typeof max === 'number') { value = Math.min(value, max); } if (typeof step === 'number' && step !== 0) { value = Math.round(value / step) * step; } return value; } + /** + * @param {string} value + * @returns {?import('input').Modifier} + */ + static normalizeModifier(value) { + switch (value) { + case 'alt': + case 'ctrl': + case 'meta': + case 'shift': + case 'mouse0': + case 'mouse1': + case 'mouse2': + case 'mouse3': + case 'mouse4': + case 'mouse5': + return value; + default: + return null; + } + } + + /** + * @param {string} value + * @returns {?import('input').ModifierKey} + */ + static normalizeModifierKey(value) { + switch (value) { + case 'alt': + case 'ctrl': + case 'meta': + case 'shift': + return value; + default: + return null; + } + } + // Private + /** + * @param {MouseEvent} event The event to check. + * @param {import('input').ModifierMouseButton[]|import('input').Modifier[]} array + */ static _getActiveButtons(event, array) { let {buttons} = event; if (typeof buttons === 'number' && buttons > 0) { for (let i = 0; i < 6; ++i) { const buttonFlag = (1 << i); if ((buttons & buttonFlag) !== 0) { - array.push(`mouse${i}`); + array.push(/** @type {import('input').ModifierMouseButton} */ (`mouse${i}`)); buttons &= ~buttonFlag; if (buttons === 0) { break; } } @@ -589,10 +633,20 @@ export class DocumentUtil { } } + /** + * @param {CSSStyleDeclaration} style + * @param {string} propertyName + * @param {string} value + */ static _setImposterStyle(style, propertyName, value) { style.setProperty(propertyName, value, 'important'); } + /** + * @param {HTMLInputElement|HTMLTextAreaElement} element + * @param {boolean} isTextarea + * @returns {[imposter: ?HTMLDivElement, container: ?HTMLDivElement]} + */ static _createImposter(element, isTextarea) { const body = document.body; if (body === null) { return [null, null]; } @@ -669,6 +723,12 @@ export class DocumentUtil { return [imposter, container]; } + /** + * @param {number} x + * @param {number} y + * @param {boolean} all + * @returns {Element[]} + */ static _getElementsFromPoint(x, y, all) { if (all) { // document.elementsFromPoint can return duplicates which must be removed. @@ -680,6 +740,13 @@ export class DocumentUtil { return e !== null ? [e] : []; } + /** + * @param {number} x + * @param {number} y + * @param {Range} range + * @param {boolean} normalizeCssZoom + * @returns {boolean} + */ static _isPointInRange(x, y, range, normalizeCssZoom) { // Require a text node to start const {startContainer} = range; @@ -722,16 +789,26 @@ export class DocumentUtil { return false; } + /** + * @param {string} string + * @returns {boolean} + */ static _isWhitespace(string) { return string.trim().length === 0; } + /** + * @param {number} x + * @param {number} y + * @returns {?Range} + */ static _caretRangeFromPoint(x, y) { if (typeof document.caretRangeFromPoint === 'function') { // Chrome, Edge return document.caretRangeFromPoint(x, y); } + // @ts-expect-error - caretPositionFromPoint is non-standard if (typeof document.caretPositionFromPoint === 'function') { // Firefox return this._caretPositionFromPoint(x, y); @@ -741,8 +818,14 @@ export class DocumentUtil { return null; } + /** + * @param {number} x + * @param {number} y + * @returns {?Range} + */ static _caretPositionFromPoint(x, y) { - const position = document.caretPositionFromPoint(x, y); + // @ts-expect-error - caretPositionFromPoint is non-standard + const position = /** @type {(x: number, y: number) => ?{offsetNode: Node, offset: number}} */ (document.caretPositionFromPoint)(x, y); if (position === null) { return null; } @@ -760,8 +843,8 @@ export class DocumentUtil { case Node.ELEMENT_NODE: // Elements with user-select: all will return the element // instead of a text point inside the element. - if (this._isElementUserSelectAll(node)) { - return this._caretPositionFromPointNormalizeStyles(x, y, node); + if (this._isElementUserSelectAll(/** @type {Element} */ (node))) { + return this._caretPositionFromPointNormalizeStyles(x, y, /** @type {Element} */ (node)); } break; } @@ -778,14 +861,23 @@ export class DocumentUtil { } } + /** + * @param {number} x + * @param {number} y + * @param {Element} nextElement + * @returns {?Range} + */ static _caretPositionFromPointNormalizeStyles(x, y, nextElement) { const previousStyles = new Map(); try { while (true) { - this._recordPreviousStyle(previousStyles, nextElement); - nextElement.style.setProperty('user-select', 'text', 'important'); + if (nextElement instanceof HTMLElement) { + this._recordPreviousStyle(previousStyles, nextElement); + nextElement.style.setProperty('user-select', 'text', 'important'); + } - const position = document.caretPositionFromPoint(x, y); + // @ts-expect-error - caretPositionFromPoint is non-standard + const position = /** @type {(x: number, y: number) => ?{offsetNode: Node, offset: number}} */ (document.caretPositionFromPoint)(x, y); if (position === null) { return null; } @@ -803,12 +895,12 @@ export class DocumentUtil { case Node.ELEMENT_NODE: // Elements with user-select: all will return the element // instead of a text point inside the element. - if (this._isElementUserSelectAll(node)) { + if (this._isElementUserSelectAll(/** @type {Element} */ (node))) { if (previousStyles.has(node)) { // Recursive return null; } - nextElement = node; + nextElement = /** @type {Element} */ (node); continue; } break; @@ -830,6 +922,13 @@ export class DocumentUtil { } } + /** + * @param {number} x + * @param {number} y + * @param {Element[]} elements + * @param {boolean} normalizeCssZoom + * @returns {?Range} + */ static _caretRangeFromPointExt(x, y, elements, normalizeCssZoom) { let previousStyles = null; try { @@ -862,6 +961,12 @@ export class DocumentUtil { } } + /** + * @param {Element[]} elements + * @param {number} i + * @param {Map<Element, ?string>} previousStyles + * @returns {number} + */ static _disableTransparentElement(elements, i, previousStyles) { while (true) { if (i >= elements.length) { @@ -870,19 +975,28 @@ export class DocumentUtil { const element = elements[i++]; if (this._isElementTransparent(element)) { - this._recordPreviousStyle(previousStyles, element); - element.style.setProperty('pointer-events', 'none', 'important'); + if (element instanceof HTMLElement) { + this._recordPreviousStyle(previousStyles, element); + element.style.setProperty('pointer-events', 'none', 'important'); + } return i; } } } + /** + * @param {Map<Element, ?string>} previousStyles + * @param {Element} element + */ static _recordPreviousStyle(previousStyles, element) { if (previousStyles.has(element)) { return; } const style = element.hasAttribute('style') ? element.getAttribute('style') : null; previousStyles.set(element, style); } + /** + * @param {Map<Element, ?string>} previousStyles + */ static _revertStyles(previousStyles) { for (const [element, style] of previousStyles.entries()) { if (style === null) { @@ -893,6 +1007,10 @@ export class DocumentUtil { } } + /** + * @param {Element} element + * @returns {boolean} + */ static _isElementTransparent(element) { if ( element === document.body || @@ -908,14 +1026,26 @@ export class DocumentUtil { ); } + /** + * @param {string} cssColor + * @returns {boolean} + */ static _isColorTransparent(cssColor) { return this._transparentColorPattern.test(cssColor); } + /** + * @param {Element} element + * @returns {boolean} + */ static _isElementUserSelectAll(element) { return getComputedStyle(element).userSelect === 'all'; } + /** + * @param {string|number|undefined} value + * @returns {?number} + */ static _convertToNumberOrNull(value) { if (typeof value !== 'number') { if (typeof value !== 'string' || value.length === 0) { @@ -926,9 +1056,12 @@ export class DocumentUtil { return !Number.isNaN(value) ? value : null; } } +/** @type {RegExp} */ // eslint-disable-next-line no-underscore-dangle DocumentUtil._transparentColorPattern = /rgba\s*\([^)]*,\s*0(?:\.0+)?\s*\)/; +/** @type {?boolean} */ // eslint-disable-next-line no-underscore-dangle DocumentUtil._cssZoomSupported = null; +/** @type {import('document-util').GetRangeFromPointHandler[]} */ // eslint-disable-next-line no-underscore-dangle DocumentUtil._getRangeFromPointHandlers = []; diff --git a/ext/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js index 4da5b0c2..cf98a243 100644 --- a/ext/js/dom/dom-data-binder.js +++ b/ext/js/dom/dom-data-binder.js @@ -20,42 +20,66 @@ import {TaskAccumulator} from '../general/task-accumulator.js'; import {DocumentUtil} from './document-util.js'; import {SelectorObserver} from './selector-observer.js'; +/** + * @template [T=unknown] + */ export class DOMDataBinder { + /** + * @param {import('dom-data-binder').ConstructorDetails<T>} details + */ constructor({selector, createElementMetadata, compareElementMetadata, getValues, setValues, onError=null}) { + /** @type {string} */ this._selector = selector; + /** @type {import('dom-data-binder').CreateElementMetadataCallback<T>} */ this._createElementMetadata = createElementMetadata; + /** @type {import('dom-data-binder').CompareElementMetadataCallback<T>} */ this._compareElementMetadata = compareElementMetadata; + /** @type {import('dom-data-binder').GetValuesCallback<T>} */ this._getValues = getValues; + /** @type {import('dom-data-binder').SetValuesCallback<T>} */ this._setValues = setValues; + /** @type {?import('dom-data-binder').OnErrorCallback<T>} */ this._onError = onError; + /** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').UpdateTaskValue>} */ this._updateTasks = new TaskAccumulator(this._onBulkUpdate.bind(this)); + /** @type {TaskAccumulator<import('dom-data-binder').ElementObserver<T>, import('dom-data-binder').AssignTaskValue>} */ this._assignTasks = new TaskAccumulator(this._onBulkAssign.bind(this)); - this._selectorObserver = new SelectorObserver({ + /** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>} */ + this._selectorObserver = /** @type {SelectorObserver<import('dom-data-binder').ElementObserver<T>>} */ (new SelectorObserver({ selector, ignoreSelector: null, onAdded: this._createObserver.bind(this), onRemoved: this._removeObserver.bind(this), onChildrenUpdated: this._onObserverChildrenUpdated.bind(this), isStale: this._isObserverStale.bind(this) - }); + })); } + /** + * @param {Element} element + */ observe(element) { this._selectorObserver.observe(element, true); } + /** */ disconnect() { this._selectorObserver.disconnect(); } + /** */ async refresh() { await this._updateTasks.enqueue(null, {all: true}); } // Private + /** + * @param {import('dom-data-binder').UpdateTask<T>[]} tasks + */ async _onBulkUpdate(tasks) { let all = false; + /** @type {import('dom-data-binder').ApplyTarget<T>[]} */ const targets = []; for (const [observer, task] of tasks) { if (observer === null) { @@ -82,17 +106,29 @@ export class DOMDataBinder { this._applyValues(targets, responses, true); } + /** + * @param {import('dom-data-binder').AssignTask<T>[]} tasks + */ async _onBulkAssign(tasks) { - const targets = tasks; - const args = targets.map(([observer, task]) => ({ - element: observer.element, - metadata: observer.metadata, - value: task.data.value - })); + /** @type {import('dom-data-binder').ApplyTarget<T>[]} */ + const targets = []; + const args = []; + for (const [observer, task] of tasks) { + if (observer === null) { continue; } + args.push({ + element: observer.element, + metadata: observer.metadata, + value: task.data.value + }); + targets.push([observer, task]); + } const responses = await this._setValues(args); this._applyValues(targets, responses, false); } + /** + * @param {import('dom-data-binder').ElementObserver<T>} observer + */ _onElementChange(observer) { const value = this._getElementValue(observer.element); observer.value = value; @@ -100,9 +136,12 @@ export class DOMDataBinder { this._assignTasks.enqueue(observer, {value}); } + /** + * @param {import('dom-data-binder').ApplyTarget<T>[]} targets + * @param {import('dom-data-binder').TaskResult[]} response + * @param {boolean} ignoreStale + */ _applyValues(targets, response, ignoreStale) { - if (!Array.isArray(response)) { return; } - for (let i = 0, ii = targets.length; i < ii; ++i) { const [observer, task] = targets[i]; const {error, result} = response[i]; @@ -123,8 +162,14 @@ export class DOMDataBinder { } } + /** + * @param {Element} element + * @returns {import('dom-data-binder').ElementObserver<T>|undefined} + */ _createObserver(element) { const metadata = this._createElementMetadata(element); + if (typeof metadata === 'undefined') { return void 0; } + /** @type {import('dom-data-binder').ElementObserver<T>} */ const observer = { element, type: this._getNormalizedElementType(element), @@ -137,76 +182,121 @@ export class DOMDataBinder { element.addEventListener('change', observer.onChange, false); - this._updateTasks.enqueue(observer); + this._updateTasks.enqueue(observer, {all: false}); return observer; } + /** + * @param {Element} element + * @param {import('dom-data-binder').ElementObserver<T>} observer + */ _removeObserver(element, observer) { + if (observer.onChange === null) { return; } element.removeEventListener('change', observer.onChange, false); observer.onChange = null; } + /** + * @param {Element} element + * @param {import('dom-data-binder').ElementObserver<T>} observer + */ _onObserverChildrenUpdated(element, observer) { if (observer.hasValue) { this._setElementValue(element, observer.value); } } + /** + * @param {Element} element + * @param {import('dom-data-binder').ElementObserver<T>} observer + * @returns {boolean} + */ _isObserverStale(element, observer) { const {type, metadata} = observer; - return !( - type === this._getNormalizedElementType(element) && - this._compareElementMetadata(metadata, this._createElementMetadata(element)) - ); + if (type !== this._getNormalizedElementType(element)) { return false; } + const newMetadata = this._createElementMetadata(element); + return typeof newMetadata === 'undefined' || !this._compareElementMetadata(metadata, newMetadata); } + /** + * @param {Element} element + * @param {unknown} value + */ _setElementValue(element, value) { switch (this._getNormalizedElementType(element)) { case 'checkbox': - element.checked = value; + /** @type {HTMLInputElement} */ (element).checked = typeof value === 'boolean' && value; break; case 'text': case 'number': case 'textarea': case 'select': - element.value = value; + /** @type {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} */ (element).value = typeof value === 'string' ? value : `${value}`; break; } - const event = new CustomEvent('settingChanged', {detail: {value}}); + /** @type {number|string|boolean} */ + let safeValue; + switch (typeof value) { + case 'number': + case 'string': + case 'boolean': + safeValue = value; + break; + default: + safeValue = `${value}`; + break; + } + /** @type {import('dom-data-binder').SettingChangedEvent} */ + const event = new CustomEvent('settingChanged', {detail: {value: safeValue}}); element.dispatchEvent(event); } + /** + * @param {Element} element + * @returns {boolean|string|number|null} + */ _getElementValue(element) { switch (this._getNormalizedElementType(element)) { case 'checkbox': - return !!element.checked; + return !!(/** @type {HTMLInputElement} */ (element).checked); case 'text': - return `${element.value}`; + return `${/** @type {HTMLInputElement} */ (element).value}`; case 'number': - return DocumentUtil.convertElementValueToNumber(element.value, element); + return DocumentUtil.convertElementValueToNumber(/** @type {HTMLInputElement} */ (element).value, /** @type {HTMLInputElement} */ (element)); case 'textarea': + return /** @type {HTMLTextAreaElement} */ (element).value; case 'select': - return element.value; + return /** @type {HTMLSelectElement} */ (element).value; } return null; } + /** + * @param {Element} element + * @returns {import('dom-data-binder').NormalizedElementType} + */ _getNormalizedElementType(element) { switch (element.nodeName.toUpperCase()) { case 'INPUT': { - let {type} = element; - if (type === 'password') { type = 'text'; } - return type; + const {type} = /** @type {HTMLInputElement} */ (element); + switch (type) { + case 'text': + case 'password': + return 'text'; + case 'number': + case 'checkbox': + return type; + } + break; } case 'TEXTAREA': return 'textarea'; case 'SELECT': return 'select'; - default: - return null; } + return null; } } diff --git a/ext/js/dom/dom-text-scanner.js b/ext/js/dom/dom-text-scanner.js index ccd1c90b..42e0acc9 100644 --- a/ext/js/dom/dom-text-scanner.js +++ b/ext/js/dom/dom-text-scanner.js @@ -36,15 +36,25 @@ export class DOMTextScanner { const resetOffset = (ruby !== null); if (resetOffset) { node = ruby; } + /** @type {Node} */ this._node = node; + /** @type {number} */ this._offset = offset; + /** @type {string} */ this._content = ''; + /** @type {number} */ this._remainder = 0; + /** @type {boolean} */ this._resetOffset = resetOffset; + /** @type {number} */ this._newlines = 0; + /** @type {boolean} */ this._lineHasWhitespace = false; + /** @type {boolean} */ this._lineHasContent = false; + /** @type {boolean} */ this._forcePreserveWhitespace = forcePreserveWhitespace; + /** @type {boolean} */ this._generateLayoutContent = generateLayoutContent; } @@ -99,8 +109,8 @@ export class DOMTextScanner { const ELEMENT_NODE = Node.ELEMENT_NODE; const generateLayoutContent = this._generateLayoutContent; - let node = this._node; - let lastNode = node; + let node = /** @type {?Node} */ (this._node); + let lastNode = /** @type {Node} */ (node); let resetOffset = this._resetOffset; let newlines = 0; while (node !== null) { @@ -111,8 +121,8 @@ export class DOMTextScanner { lastNode = node; if (!( forward ? - this._seekTextNodeForward(node, resetOffset) : - this._seekTextNodeBackward(node, resetOffset) + this._seekTextNodeForward(/** @type {Text} */ (node), resetOffset) : + this._seekTextNodeBackward(/** @type {Text} */ (node), resetOffset) )) { // Length reached break; @@ -120,18 +130,19 @@ export class DOMTextScanner { } else if (nodeType === ELEMENT_NODE) { lastNode = node; this._offset = 0; - ({enterable, newlines} = DOMTextScanner.getElementSeekInfo(node)); + ({enterable, newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (node))); if (newlines > this._newlines && generateLayoutContent) { this._newlines = newlines; } } + /** @type {Node[]} */ const exitedNodes = []; node = DOMTextScanner.getNextNode(node, forward, enterable, exitedNodes); for (const exitedNode of exitedNodes) { if (exitedNode.nodeType !== ELEMENT_NODE) { continue; } - ({newlines} = DOMTextScanner.getElementSeekInfo(exitedNode)); + ({newlines} = DOMTextScanner.getElementSeekInfo(/** @type {Element} */ (exitedNode))); if (newlines > this._newlines && generateLayoutContent) { this._newlines = newlines; } @@ -155,7 +166,7 @@ export class DOMTextScanner { * @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached. */ _seekTextNodeForward(textNode, resetOffset) { - const nodeValue = textNode.nodeValue; + const nodeValue = /** @type {string} */ (textNode.nodeValue); const nodeValueLength = nodeValue.length; const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode); @@ -241,7 +252,7 @@ export class DOMTextScanner { * @returns {boolean} `true` if scanning should continue, or `false` if the scan length has been reached. */ _seekTextNodeBackward(textNode, resetOffset) { - const nodeValue = textNode.nodeValue; + const nodeValue = /** @type {string} */ (textNode.nodeValue); const nodeValueLength = nodeValue.length; const {preserveNewlines, preserveWhitespace} = this._getWhitespaceSettings(textNode); @@ -350,6 +361,7 @@ export class DOMTextScanner { * @returns {?Node} The next node in the document, or `null` if there is no next node. */ static getNextNode(node, forward, visitChildren, exitedNodes) { + /** @type {?Node} */ let next = visitChildren ? (forward ? node.firstChild : node.lastChild) : null; if (next === null) { while (true) { @@ -369,14 +381,17 @@ export class DOMTextScanner { /** * Gets the parent element of a given Node. - * @param {Node} node The node to check. - * @returns {?Node} The parent element if one exists, otherwise `null`. + * @param {?Node} node The node to check. + * @returns {?Element} The parent element if one exists, otherwise `null`. */ static getParentElement(node) { - while (node !== null && node.nodeType !== Node.ELEMENT_NODE) { + while (node !== null) { + if (node.nodeType === Node.ELEMENT_NODE) { + return /** @type {Element} */ (node); + } node = node.parentNode; } - return node; + return null; } /** @@ -387,11 +402,12 @@ export class DOMTextScanner { * @returns {?HTMLElement} A <ruby> node if the input node is contained in one, otherwise `null`. */ static getParentRubyElement(node) { - node = DOMTextScanner.getParentElement(node); - if (node !== null && node.nodeName.toUpperCase() === 'RT') { - node = node.parentNode; - if (node !== null && node.nodeName.toUpperCase() === 'RUBY') { - return node; + /** @type {?Node} */ + let node2 = DOMTextScanner.getParentElement(node); + if (node2 !== null && node2.nodeName.toUpperCase() === 'RT') { + node2 = node2.parentNode; + if (node2 !== null && node2.nodeName.toUpperCase() === 'RUBY') { + return /** @type {HTMLElement} */ (node2); } } return null; @@ -505,7 +521,9 @@ export class DOMTextScanner { return !( style.userSelect === 'none' || style.webkitUserSelect === 'none' || + // @ts-expect-error - vendor prefix style.MozUserSelect === 'none' || + // @ts-expect-error - vendor prefix style.msUserSelect === 'none' ); } @@ -513,7 +531,7 @@ export class DOMTextScanner { /** * Checks whether a CSS color is transparent or not. * @param {string} cssColor A CSS color string, expected to be encoded in rgb(a) form. - * @returns {false} `true` if the color is transparent, otherwise `false`. + * @returns {boolean} `true` if the color is transparent, otherwise `false`. */ static isCSSColorTransparent(cssColor) { return ( diff --git a/ext/js/dom/html-template-collection.js b/ext/js/dom/html-template-collection.js index 100ba55c..62d18224 100644 --- a/ext/js/dom/html-template-collection.js +++ b/ext/js/dom/html-template-collection.js @@ -17,9 +17,15 @@ */ export class HtmlTemplateCollection { - constructor(source) { + constructor() { + /** @type {Map<string, HTMLTemplateElement>} */ this._templates = new Map(); + } + /** + * @param {string|Document} source + */ + load(source) { const sourceNode = ( typeof source === 'string' ? new DOMParser().parseFromString(source, 'text/html') : @@ -35,28 +41,53 @@ export class HtmlTemplateCollection { } } + /** + * @template {Element} T + * @param {string} name + * @returns {T} + * @throws {Error} + */ instantiate(name) { const template = this._templates.get(name); - return document.importNode(template.content.firstChild, true); + if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); } + const {firstElementChild} = template.content; + if (firstElementChild === null) { throw new Error(`Failed to find template content element: ${name}`); } + return /** @type {T} */ (document.importNode(firstElementChild, true)); } + /** + * @param {string} name + * @returns {DocumentFragment} + * @throws {Error} + */ instantiateFragment(name) { const template = this._templates.get(name); - return document.importNode(template.content, true); + if (typeof template === 'undefined') { throw new Error(`Failed to find template: ${name}`); } + const {content} = template; + return document.importNode(content, true); } + /** + * @returns {IterableIterator<HTMLTemplateElement>} + */ getAllTemplates() { return this._templates.values(); } // Private + /** + * @param {HTMLTemplateElement} template + */ _prepareTemplate(template) { if (template.dataset.removeWhitespaceText === 'true') { this._removeWhitespaceText(template); } } + /** + * @param {HTMLTemplateElement} template + */ _removeWhitespaceText(template) { const {content} = template; const {TEXT_NODE} = Node; @@ -65,7 +96,7 @@ export class HtmlTemplateCollection { while (true) { const node = iterator.nextNode(); if (node === null) { break; } - if (node.nodeType === TEXT_NODE && node.nodeValue.trim().length === 0) { + if (node.nodeType === TEXT_NODE && /** @type {string} */ (node.nodeValue).trim().length === 0) { removeNodes.push(node); } } diff --git a/ext/js/dom/native-simple-dom-parser.js b/ext/js/dom/native-simple-dom-parser.js index 882469a0..418a4e3c 100644 --- a/ext/js/dom/native-simple-dom-parser.js +++ b/ext/js/dom/native-simple-dom-parser.js @@ -17,35 +17,89 @@ */ export class NativeSimpleDOMParser { + /** + * @param {string} content + */ constructor(content) { + /** @type {Document} */ this._document = new DOMParser().parseFromString(content, 'text/html'); } - getElementById(id, root=null) { - return (root || this._document).querySelector(`[id='${id}']`); + /** + * @param {string} id + * @param {import('simple-dom-parser').Element} [root] + * @returns {?import('simple-dom-parser').Element} + */ + getElementById(id, root) { + return this._convertElementOrDocument(root).querySelector(`[id='${id}']`); } - getElementByTagName(tagName, root=null) { - return (root || this._document).querySelector(tagName); + /** + * @param {string} tagName + * @param {import('simple-dom-parser').Element} [root] + * @returns {?import('simple-dom-parser').Element} + */ + getElementByTagName(tagName, root) { + return this._convertElementOrDocument(root).querySelector(tagName); } - getElementsByTagName(tagName, root=null) { - return [...(root || this._document).querySelectorAll(tagName)]; + /** + * @param {string} tagName + * @param {import('simple-dom-parser').Element} [root] + * @returns {import('simple-dom-parser').Element[]} + */ + getElementsByTagName(tagName, root) { + return [...this._convertElementOrDocument(root).querySelectorAll(tagName)]; } - getElementsByClassName(className, root=null) { - return [...(root || this._document).querySelectorAll(`.${className}`)]; + /** + * @param {string} className + * @param {import('simple-dom-parser').Element} [root] + * @returns {import('simple-dom-parser').Element[]} + */ + getElementsByClassName(className, root) { + return [...this._convertElementOrDocument(root).querySelectorAll(`.${className}`)]; } + /** + * @param {import('simple-dom-parser').Element} element + * @param {string} attribute + * @returns {?string} + */ getAttribute(element, attribute) { - return element.hasAttribute(attribute) ? element.getAttribute(attribute) : null; + const element2 = this._convertElement(element); + return element2.hasAttribute(attribute) ? element2.getAttribute(attribute) : null; } + /** + * @param {import('simple-dom-parser').Element} element + * @returns {string} + */ getTextContent(element) { - return element.textContent; + const {textContent} = this._convertElement(element); + return typeof textContent === 'string' ? textContent : ''; } + /** + * @returns {boolean} + */ static isSupported() { return typeof DOMParser !== 'undefined'; } + + /** + * @param {import('simple-dom-parser').Element} element + * @returns {Element} + */ + _convertElement(element) { + return /** @type {Element} */ (element); + } + + /** + * @param {import('simple-dom-parser').Element|undefined} element + * @returns {Element|Document} + */ + _convertElementOrDocument(element) { + return typeof element !== 'undefined' ? /** @type {Element} */ (element) : this._document; + } } diff --git a/ext/js/dom/panel-element.js b/ext/js/dom/panel-element.js index 9b056920..748c3a36 100644 --- a/ext/js/dom/panel-element.js +++ b/ext/js/dom/panel-element.js @@ -18,25 +18,45 @@ import {EventDispatcher} from '../core.js'; +/** + * @augments EventDispatcher<import('panel-element').EventType> + */ export class PanelElement extends EventDispatcher { + /** + * @param {import('panel-element').ConstructorDetails} details + */ constructor({node, closingAnimationDuration}) { super(); + /** @type {HTMLElement} */ this._node = node; + /** @type {number} */ this._closingAnimationDuration = closingAnimationDuration; + /** @type {string} */ this._hiddenAnimatingClass = 'hidden-animating'; + /** @type {?MutationObserver} */ this._mutationObserver = null; + /** @type {boolean} */ this._visible = false; + /** @type {?import('core').Timeout} */ this._closeTimer = null; } + /** @type {HTMLElement} */ get node() { return this._node; } + /** + * @returns {boolean} + */ isVisible() { return !this._node.hidden; } + /** + * @param {boolean} value + * @param {boolean} [animate] + */ setVisible(value, animate=true) { value = !!value; if (this.isVisible() === value) { return; } @@ -63,6 +83,11 @@ export class PanelElement extends EventDispatcher { } } + /** + * @param {import('panel-element').EventType} eventName + * @param {(details: import('core').SafeAny) => void} callback + * @returns {void} + */ on(eventName, callback) { if (eventName === 'visibilityChanged') { if (this._mutationObserver === null) { @@ -78,6 +103,11 @@ export class PanelElement extends EventDispatcher { return super.on(eventName, callback); } + /** + * @param {import('panel-element').EventType} eventName + * @param {(details: import('core').SafeAny) => void} callback + * @returns {boolean} + */ off(eventName, callback) { const result = super.off(eventName, callback); if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) { @@ -91,6 +121,7 @@ export class PanelElement extends EventDispatcher { // Private + /** */ _onMutation() { const visible = this.isVisible(); if (this._visible === visible) { return; } @@ -98,6 +129,9 @@ export class PanelElement extends EventDispatcher { this.trigger('visibilityChanged', {visible}); } + /** + * @param {boolean} reopening + */ _completeClose(reopening) { this._closeTimer = null; this._node.classList.remove(this._hiddenAnimatingClass); diff --git a/ext/js/dom/popup-menu.js b/ext/js/dom/popup-menu.js index 7cae1dff..78394c93 100644 --- a/ext/js/dom/popup-menu.js +++ b/ext/js/dom/popup-menu.js @@ -18,38 +18,58 @@ import {EventDispatcher, EventListenerCollection} from '../core.js'; +/** + * @augments EventDispatcher<import('popup-menu').EventType> + */ export class PopupMenu extends EventDispatcher { + /** + * @param {HTMLElement} sourceElement + * @param {HTMLElement} containerNode + */ constructor(sourceElement, containerNode) { super(); + /** @type {HTMLElement} */ this._sourceElement = sourceElement; + /** @type {HTMLElement} */ this._containerNode = containerNode; - this._node = containerNode.querySelector('.popup-menu'); - this._bodyNode = containerNode.querySelector('.popup-menu-body'); + /** @type {HTMLElement} */ + this._node = /** @type {HTMLElement} */ (containerNode.querySelector('.popup-menu')); + /** @type {HTMLElement} */ + this._bodyNode = /** @type {HTMLElement} */ (containerNode.querySelector('.popup-menu-body')); + /** @type {boolean} */ this._isClosed = false; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {EventListenerCollection} */ this._itemEventListeners = new EventListenerCollection(); } + /** @type {HTMLElement} */ get sourceElement() { return this._sourceElement; } + /** @type {HTMLElement} */ get containerNode() { return this._containerNode; } + /** @type {HTMLElement} */ get node() { return this._node; } + /** @type {HTMLElement} */ get bodyNode() { return this._bodyNode; } + /** @type {boolean} */ get isClosed() { return this._isClosed; } + /** */ prepare() { this._setPosition(); this._containerNode.focus(); @@ -61,17 +81,25 @@ export class PopupMenu extends EventDispatcher { PopupMenu.openMenus.add(this); + /** @type {import('popup-menu').MenuOpenEventDetails} */ + const detail = {menu: this}; + this._sourceElement.dispatchEvent(new CustomEvent('menuOpen', { bubbles: false, cancelable: false, - detail: {menu: this} + detail })); } + /** + * @param {boolean} [cancelable] + * @returns {boolean} + */ close(cancelable=true) { - return this._close(null, 'close', cancelable, {}); + return this._close(null, 'close', cancelable, null); } + /** */ updateMenuItems() { this._itemEventListeners.removeAllEventListeners(); const items = this._bodyNode.querySelectorAll('.popup-menu-item'); @@ -81,12 +109,16 @@ export class PopupMenu extends EventDispatcher { } } + /** */ updatePosition() { this._setPosition(); } // Private + /** + * @param {MouseEvent} e + */ _onMenuContainerClick(e) { if (e.currentTarget !== e.target) { return; } if (this._close(null, 'outside', true, e)) { @@ -95,8 +127,11 @@ export class PopupMenu extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ _onMenuItemClick(e) { - const item = e.currentTarget; + const item = /** @type {HTMLButtonElement} */ (e.currentTarget); if (item.disabled) { return; } if (this._close(item, 'item', true, e)) { e.stopPropagation(); @@ -104,10 +139,12 @@ export class PopupMenu extends EventDispatcher { } } + /** */ _onWindowResize() { - this._close(null, 'resize', true, {}); + this._close(null, 'resize', true, null); } + /** */ _setPosition() { // Get flags let horizontal = 1; @@ -187,11 +224,29 @@ export class PopupMenu extends EventDispatcher { menu.style.top = `${y}px`; } + /** + * @param {?HTMLElement} item + * @param {import('popup-menu').CloseReason} cause + * @param {boolean} cancelable + * @param {?MouseEvent} originalEvent + * @returns {boolean} + */ _close(item, cause, cancelable, originalEvent) { if (this._isClosed) { return true; } - const action = (item !== null ? item.dataset.menuAction : null); + /** @type {?string} */ + let action = null; + if (item !== null) { + const {menuAction} = item.dataset; + if (typeof menuAction === 'string') { action = menuAction; } + } + + const {altKey, ctrlKey, metaKey, shiftKey} = ( + originalEvent !== null ? + originalEvent : + {altKey: false, ctrlKey: false, metaKey: false, shiftKey: false} + ); - const {altKey=false, ctrlKey=false, metaKey=false, shiftKey=false} = originalEvent; + /** @type {import('popup-menu').MenuCloseEventDetails} */ const detail = { menu: this, item, diff --git a/ext/js/dom/sandbox/css-style-applier.js b/ext/js/dom/sandbox/css-style-applier.js index 4f570bb7..ea36a02d 100644 --- a/ext/js/dom/sandbox/css-style-applier.js +++ b/ext/js/dom/sandbox/css-style-applier.js @@ -22,40 +22,20 @@ */ export class CssStyleApplier { /** - * A CSS rule. - * @typedef {object} CssRule - * @property {string} selectors A CSS selector string representing one or more selectors. - * @property {CssDeclaration[]} styles A list of CSS property and value pairs. - */ - - /** - * A single CSS property declaration. - * @typedef {object} CssDeclaration - * @property {string} property A CSS property's name, using kebab-case. - * @property {string} value The property's value. - */ - - /* eslint-disable jsdoc/check-line-alignment */ - /** * Creates a new instance of the class. - * @param {string} styleDataUrl The local URL to the JSON file containing the style rules. - * The style rules should be of the format: - * ``` - * [ - * { - * selectors: [(selector:string)...], - * styles: [ - * [(property:string), (value:string)]... - * ] - * }... - * ] - * ``` + * @param {string} styleDataUrl The local URL to the JSON file continaing the style rules. + * The style rules should follow the format of `CssStyleApplierRawStyleData`. */ constructor(styleDataUrl) { + /** @type {string} */ this._styleDataUrl = styleDataUrl; + /** @type {import('css-style-applier').CssRule[]} */ this._styleData = []; + /** @type {Map<string, import('css-style-applier').CssRule[]>} */ this._cachedRules = new Map(); + /** @type {RegExp} */ this._patternHtmlWhitespace = /[\t\r\n\f ]+/g; + /** @type {RegExp} */ this._patternClassNameCharacter = /[0-9a-zA-Z-_]/; } /* eslint-enable jsdoc/check-line-alignment */ @@ -64,7 +44,8 @@ export class CssStyleApplier { * Loads the data file for use. */ async prepare() { - let rawData; + /** @type {import('css-style-applier').RawStyleData} */ + let rawData = []; try { rawData = await this._fetchJsonAsset(this._styleDataUrl); } catch (e) { @@ -94,7 +75,7 @@ export class CssStyleApplier { const elementStyles = []; for (const element of elements) { const className = element.getAttribute('class'); - if (className.length === 0) { continue; } + if (className === null || className.length === 0) { continue; } let cssTextNew = ''; for (const {selectors, styles} of this._getCandidateCssRulesForClass(className)) { if (!element.matches(selectors)) { continue; } @@ -139,7 +120,7 @@ export class CssStyleApplier { /** * Gets an array of candidate CSS rules which might match a specific class. * @param {string} className A whitespace-separated list of classes. - * @returns {CssRule[]} An array of candidate CSS rules. + * @returns {import('css-style-applier').CssRule[]} An array of candidate CSS rules. */ _getCandidateCssRulesForClass(className) { let rules = this._cachedRules.get(className); @@ -159,7 +140,7 @@ export class CssStyleApplier { /** * Converts an array of CSS rules to a CSS string. - * @param {CssRule[]} styles An array of CSS rules. + * @param {import('css-style-applier').CssDeclaration[]} styles An array of CSS rules. * @returns {string} The CSS string. */ _getCssText(styles) { diff --git a/ext/js/dom/scroll-element.js b/ext/js/dom/scroll-element.js index 4b2ac70d..95f5ce5b 100644 --- a/ext/js/dom/scroll-element.js +++ b/ext/js/dom/scroll-element.js @@ -17,39 +17,68 @@ */ export class ScrollElement { + /** + * @param {Element} node + */ constructor(node) { + /** @type {Element} */ this._node = node; + /** @type {?number} */ this._animationRequestId = null; + /** @type {number} */ this._animationStartTime = 0; + /** @type {number} */ this._animationStartX = 0; + /** @type {number} */ this._animationStartY = 0; + /** @type {number} */ this._animationEndTime = 0; + /** @type {number} */ this._animationEndX = 0; + /** @type {number} */ this._animationEndY = 0; + /** @type {(time: number) => void} */ this._requestAnimationFrameCallback = this._onAnimationFrame.bind(this); } + /** @type {number} */ get x() { return this._node !== null ? this._node.scrollLeft : window.scrollX || window.pageXOffset; } + /** @type {number} */ get y() { return this._node !== null ? this._node.scrollTop : window.scrollY || window.pageYOffset; } + /** + * @param {number} y + */ toY(y) { this.to(this.x, y); } + /** + * @param {number} x + */ toX(x) { this.to(x, this.y); } + /** + * @param {number} x + * @param {number} y + */ to(x, y) { this.stop(); this._scroll(x, y); } + /** + * @param {number} x + * @param {number} y + * @param {number} time + */ animate(x, y, time) { this._animationStartX = this.x; this._animationStartY = this.y; @@ -60,6 +89,7 @@ export class ScrollElement { this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); } + /** */ stop() { if (this._animationRequestId === null) { return; @@ -69,12 +99,18 @@ export class ScrollElement { this._animationRequestId = null; } + /** + * @returns {DOMRect} + */ getRect() { return this._node.getBoundingClientRect(); } // Private + /** + * @param {number} time + */ _onAnimationFrame(time) { if (time >= this._animationEndTime) { this._scroll(this._animationEndX, this._animationEndY); @@ -91,6 +127,10 @@ export class ScrollElement { this._animationRequestId = window.requestAnimationFrame(this._requestAnimationFrameCallback); } + /** + * @param {number} t + * @returns {number} + */ _easeInOutCubic(t) { if (t < 0.5) { return (4.0 * t * t * t); @@ -100,10 +140,20 @@ export class ScrollElement { } } + /** + * @param {number} start + * @param {number} end + * @param {number} percent + * @returns {number} + */ _lerp(start, end, percent) { return (end - start) * percent + start; } + /** + * @param {number} x + * @param {number} y + */ _scroll(x, y) { if (this._node !== null) { this._node.scrollLeft = x; diff --git a/ext/js/dom/selector-observer.js b/ext/js/dom/selector-observer.js index e0e3d4ff..2cf46543 100644 --- a/ext/js/dom/selector-observer.js +++ b/ext/js/dom/selector-observer.js @@ -18,55 +18,35 @@ /** * Class which is used to observe elements matching a selector in specific element. + * @template T */ export class SelectorObserver { /** - * @function OnAddedCallback - * @param {Element} element The element which was added. - * @returns {*} Custom data which is assigned to element and passed to callbacks. - */ - - /** - * @function OnRemovedCallback - * @param {Element} element The element which was removed. - * @param {*} data The custom data corresponding to the element. - */ - - /** - * @function OnChildrenUpdatedCallback - * @param {Element} element The element which had its children updated. - * @param {*} data The custom data corresponding to the element. - */ - - /** - * @function IsStaleCallback - * @param {Element} element The element which had its children updated. - * @param {*} data The custom data corresponding to the element. - * @returns {boolean} Whether or not the data is stale for the element. - */ - - /** * Creates a new instance. - * @param {object} details The configuration for the object. - * @param {string} details.selector A string CSS selector used to find elements. - * @param {?string} details.ignoreSelector A string CSS selector used to filter elements, or `null` for no filtering. - * @param {OnAddedCallback} details.onAdded A function which is invoked for each element that is added that matches the selector. - * @param {?OnRemovedCallback} details.onRemoved A function which is invoked for each element that is removed, or `null`. - * @param {?OnChildrenUpdatedCallback} details.onChildrenUpdated A function which is invoked for each element which has its children updated, or `null`. - * @param {?IsStaleCallback} details.isStale A function which checks if the data is stale for a given element, or `null`. - * If the element is stale, it will be removed and potentially re-added. + * @param {import('selector-observer').ConstructorDetails<T>} details The configuration for the object. */ constructor({selector, ignoreSelector=null, onAdded=null, onRemoved=null, onChildrenUpdated=null, isStale=null}) { + /** @type {string} */ this._selector = selector; + /** @type {?string} */ this._ignoreSelector = ignoreSelector; + /** @type {?import('selector-observer').OnAddedCallback<T>} */ this._onAdded = onAdded; + /** @type {?import('selector-observer').OnRemovedCallback<T>} */ this._onRemoved = onRemoved; + /** @type {?import('selector-observer').OnChildrenUpdatedCallback<T>} */ this._onChildrenUpdated = onChildrenUpdated; + /** @type {?import('selector-observer').IsStaleCallback<T>} */ this._isStale = isStale; + /** @type {?Element} */ this._observingElement = null; + /** @type {MutationObserver} */ this._mutationObserver = new MutationObserver(this._onMutation.bind(this)); + /** @type {Map<Node, import('selector-observer').Observer<T>>} */ this._elementMap = new Map(); // Map([element => observer]...) + /** @type {Map<Node, Set<import('selector-observer').Observer<T>>>} */ this._elementAncestorMap = new Map(); // Map([element => Set([observer]...)]...) + /** @type {boolean} */ this._isObserving = false; } @@ -100,9 +80,10 @@ export class SelectorObserver { subtree: true }); + const {parentNode} = element; this._onMutation([{ type: 'childList', - target: element.parentNode, + target: parentNode !== null ? parentNode : element, addedNodes: [element], removedNodes: [] }]); @@ -124,17 +105,19 @@ export class SelectorObserver { /** * Returns an iterable list of [element, data] pairs. - * @yields A sequence of [element, data] pairs. + * @yields {[element: Element, data: T]} A sequence of [element, data] pairs. + * @returns {Generator<[element: Element, data: T], void, unknown>} */ *entries() { - for (const [element, {data}] of this._elementMap) { + for (const {element, data} of this._elementMap.values()) { yield [element, data]; } } /** * Returns an iterable list of data for every element. - * @yields A sequence of data values. + * @yields {T} A sequence of data values. + * @returns {Generator<T, void, unknown>} */ *datas() { for (const {data} of this._elementMap.values()) { @@ -144,6 +127,9 @@ export class SelectorObserver { // Private + /** + * @param {(MutationRecord|import('selector-observer').MutationRecordLike)[]} mutationList + */ _onMutation(mutationList) { for (const mutation of mutationList) { switch (mutation.type) { @@ -157,6 +143,9 @@ export class SelectorObserver { } } + /** + * @param {MutationRecord|import('selector-observer').MutationRecordLike} record + */ _onChildListMutation({addedNodes, removedNodes, target}) { const selector = this._selector; const ELEMENT_NODE = Node.ELEMENT_NODE; @@ -171,10 +160,10 @@ export class SelectorObserver { for (const node of addedNodes) { if (node.nodeType !== ELEMENT_NODE) { continue; } - if (node.matches(selector)) { - this._createObserver(node); + if (/** @type {Element} */ (node).matches(selector)) { + this._createObserver(/** @type {Element} */ (node)); } - for (const childNode of node.querySelectorAll(selector)) { + for (const childNode of /** @type {Element} */ (node).querySelectorAll(selector)) { this._createObserver(childNode); } } @@ -183,7 +172,7 @@ export class SelectorObserver { this._onChildrenUpdated !== null && (addedNodes.length !== 0 || addedNodes.length !== 0) ) { - for (let node = target; node !== null; node = node.parentNode) { + for (let node = /** @type {?Node} */ (target); node !== null; node = node.parentNode) { const observer = this._elementMap.get(node); if (typeof observer !== 'undefined') { this._onObserverChildrenUpdated(observer); @@ -192,9 +181,12 @@ export class SelectorObserver { } } + /** + * @param {MutationRecord|import('selector-observer').MutationRecordLike} record + */ _onAttributeMutation({target}) { const selector = this._selector; - const observers = this._elementAncestorMap.get(target); + const observers = this._elementAncestorMap.get(/** @type {Element} */ (target)); if (typeof observers !== 'undefined') { for (const observer of observers) { const element = observer.element; @@ -208,15 +200,19 @@ export class SelectorObserver { } } - if (target.matches(selector)) { - this._createObserver(target); + if (/** @type {Element} */ (target).matches(selector)) { + this._createObserver(/** @type {Element} */ (target)); } } + /** + * @param {Element} element + */ _createObserver(element) { if (this._elementMap.has(element) || this._shouldIgnoreElement(element) || this._onAdded === null) { return; } const data = this._onAdded(element); + if (typeof data === 'undefined') { return; } const ancestors = this._getAncestors(element); const observer = {element, ancestors, data}; @@ -232,6 +228,9 @@ export class SelectorObserver { } } + /** + * @param {import('selector-observer').Observer<T>} observer + */ _removeObserver(observer) { const {element, ancestors, data} = observer; @@ -252,26 +251,42 @@ export class SelectorObserver { } } + /** + * @param {import('selector-observer').Observer<T>} observer + */ _onObserverChildrenUpdated(observer) { + if (this._onChildrenUpdated === null) { return; } this._onChildrenUpdated(observer.element, observer.data); } + /** + * @param {import('selector-observer').Observer<T>} observer + * @returns {boolean} + */ _isObserverStale(observer) { return (this._isStale !== null && this._isStale(observer.element, observer.data)); } + /** + * @param {Element} element + * @returns {boolean} + */ _shouldIgnoreElement(element) { return (this._ignoreSelector !== null && element.matches(this._ignoreSelector)); } + /** + * @param {Node} node + * @returns {Node[]} + */ _getAncestors(node) { const root = this._observingElement; const results = []; - while (true) { - results.push(node); - if (node === root) { break; } - node = node.parentNode; - if (node === null) { break; } + let n = /** @type {?Node} */ (node); + while (n !== null) { + results.push(n); + if (n === root) { break; } + n = n.parentNode; } return results; } diff --git a/ext/js/dom/simple-dom-parser.js b/ext/js/dom/simple-dom-parser.js index 3e84b783..bca1cd88 100644 --- a/ext/js/dom/simple-dom-parser.js +++ b/ext/js/dom/simple-dom-parser.js @@ -18,55 +18,91 @@ import * as parse5 from '../../lib/parse5.js'; +/** + * @augments import('simple-dom-parser').ISimpleDomParser + */ export class SimpleDOMParser { + /** + * @param {string} content + */ constructor(content) { - this._document = parse5.parse(content); + /** @type {import('parse5')} */ + // @ts-expect-error - parse5 global is not defined in typescript declaration + this._parse5Lib = /** @type {import('parse5')} */ (parse5); + /** @type {import('parse5').TreeAdapter<import('parse5').DefaultTreeAdapterMap>} */ + this._treeAdapter = this._parse5Lib.defaultTreeAdapter; + /** @type {import('simple-dom-parser').Parse5Document} */ + this._document = this._parse5Lib.parse(content, { + treeAdapter: this._treeAdapter + }); + /** @type {RegExp} */ this._patternHtmlWhitespace = /[\t\r\n\f ]+/g; } - getElementById(id, root=null) { + /** + * @param {string} id + * @param {import('simple-dom-parser').Element} [root] + * @returns {?import('simple-dom-parser').Element} + */ + getElementById(id, root) { for (const node of this._allNodes(root)) { - if (typeof node.tagName === 'string' && this.getAttribute(node, 'id') === id) { - return node; - } + if (!this._treeAdapter.isElementNode(node) || this.getAttribute(node, 'id') !== id) { continue; } + return node; } return null; } - getElementByTagName(tagName, root=null) { + /** + * @param {string} tagName + * @param {import('simple-dom-parser').Element} [root] + * @returns {?import('simple-dom-parser').Element} + */ + getElementByTagName(tagName, root) { for (const node of this._allNodes(root)) { - if (node.tagName === tagName) { - return node; - } + if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; } + return node; } return null; } - getElementsByTagName(tagName, root=null) { + /** + * @param {string} tagName + * @param {import('simple-dom-parser').Element} [root] + * @returns {import('simple-dom-parser').Element[]} + */ + getElementsByTagName(tagName, root) { const results = []; for (const node of this._allNodes(root)) { - if (node.tagName === tagName) { - results.push(node); - } + if (!this._treeAdapter.isElementNode(node) || node.tagName !== tagName) { continue; } + results.push(node); } return results; } - getElementsByClassName(className, root=null) { + /** + * @param {string} className + * @param {import('simple-dom-parser').Element} [root] + * @returns {import('simple-dom-parser').Element[]} + */ + getElementsByClassName(className, root) { const results = []; for (const node of this._allNodes(root)) { - if (typeof node.tagName === 'string') { - const nodeClassName = this.getAttribute(node, 'class'); - if (nodeClassName !== null && this._hasToken(nodeClassName, className)) { - results.push(node); - } + if (!this._treeAdapter.isElementNode(node)) { continue; } + const nodeClassName = this.getAttribute(node, 'class'); + if (nodeClassName !== null && this._hasToken(nodeClassName, className)) { + results.push(node); } } return results; } + /** + * @param {import('simple-dom-parser').Element} element + * @param {string} attribute + * @returns {?string} + */ getAttribute(element, attribute) { - for (const attr of element.attrs) { + for (const attr of /** @type {import('simple-dom-parser').Parse5Element} */ (element).attrs) { if ( attr.name === attribute && typeof attr.namespace === 'undefined' @@ -77,43 +113,62 @@ export class SimpleDOMParser { return null; } + /** + * @param {import('simple-dom-parser').Element} element + * @returns {string} + */ getTextContent(element) { let source = ''; for (const node of this._allNodes(element)) { - if (node.nodeName === '#text') { + if (this._treeAdapter.isTextNode(node)) { source += node.value; } } return source; } + /** + * @returns {boolean} + */ static isSupported() { return typeof parse5 !== 'undefined'; } // Private + /** + * @param {import('simple-dom-parser').Element|undefined} root + * @returns {Generator<import('simple-dom-parser').Parse5ChildNode, void, unknown>} + * @yields {import('simple-dom-parser').Parse5ChildNode} + */ *_allNodes(root) { - if (root === null) { - root = this._document; - } - // Depth-first pre-order traversal - const nodeQueue = [root]; + /** @type {import('simple-dom-parser').Parse5ChildNode[]} */ + const nodeQueue = []; + if (typeof root !== 'undefined') { + nodeQueue.push(/** @type {import('simple-dom-parser').Parse5Element} */ (root)); + } else { + nodeQueue.push(...this._document.childNodes); + } while (nodeQueue.length > 0) { - const node = nodeQueue.pop(); - + const node = /** @type {import('simple-dom-parser').Parse5ChildNode} */ (nodeQueue.pop()); yield node; - - const childNodes = node.childNodes; - if (typeof childNodes !== 'undefined') { - for (let i = childNodes.length - 1; i >= 0; --i) { - nodeQueue.push(childNodes[i]); + if (this._treeAdapter.isElementNode(node)) { + const {childNodes} = node; + if (typeof childNodes !== 'undefined') { + for (let i = childNodes.length - 1; i >= 0; --i) { + nodeQueue.push(childNodes[i]); + } } } } } + /** + * @param {string} tokenListString + * @param {string} token + * @returns {boolean} + */ _hasToken(tokenListString, token) { let start = 0; const pattern = this._patternHtmlWhitespace; diff --git a/ext/js/dom/text-source-element.js b/ext/js/dom/text-source-element.js index 95534975..40ff5cc9 100644 --- a/ext/js/dom/text-source-element.js +++ b/ext/js/dom/text-source-element.js @@ -18,7 +18,6 @@ import {StringUtil} from '../data/sandbox/string-util.js'; import {DocumentUtil} from './document-util.js'; -import {TextSourceRange} from './text-source-range.js'; /** * This class represents a text source that is attached to a HTML element, such as an <img> @@ -33,16 +32,21 @@ export class TextSourceElement { * @param {number} endOffset The text end offset position within the full content. */ constructor(element, fullContent, startOffset, endOffset) { + /** @type {Element} */ this._element = element; + /** @type {string} */ this._fullContent = fullContent; + /** @type {number} */ this._startOffset = startOffset; + /** @type {number} */ this._endOffset = endOffset; + /** @type {string} */ this._content = this._fullContent.substring(this._startOffset, this._endOffset); } /** * Gets the type name of this instance. - * @type {string} + * @type {'element'} */ get type() { return 'element'; @@ -147,7 +151,7 @@ export class TextSourceElement { /** * Gets writing mode that is used for this element. * See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode. - * @returns {string} The rects. + * @returns {import('document-util').NormalizedWritingMode} The writing mode. */ getWritingMode() { return 'horizontal-tb'; @@ -169,7 +173,7 @@ export class TextSourceElement { /** * Checks whether another text source has the same starting point. - * @param {TextSourceElement|TextSourceRange} other The other source to test. + * @param {import('text-source').TextSource} other The other source to test. * @returns {boolean} `true` if the starting points are equivalent, `false` otherwise. */ hasSameStart(other) { @@ -206,23 +210,39 @@ export class TextSourceElement { * @returns {string} The content string. */ static _getElementContent(element) { - let content; + let content = ''; switch (element.nodeName.toUpperCase()) { case 'BUTTON': - content = element.textContent; + { + const {textContent} = /** @type {HTMLButtonElement} */ (element); + if (textContent !== null) { + content = textContent; + } + } break; case 'IMG': - content = element.getAttribute('alt') || ''; + { + const alt = /** @type {HTMLImageElement} */ (element).getAttribute('alt'); + if (typeof alt === 'string') { + content = alt; + } + } break; case 'SELECT': { - const {selectedIndex, options} = element; - const option = (selectedIndex >= 0 && selectedIndex < options.length ? options[selectedIndex] : null); - content = (option !== null ? option.textContent : ''); + const {selectedIndex, options} = /** @type {HTMLSelectElement} */ (element); + if (selectedIndex >= 0 && selectedIndex < options.length) { + const {textContent} = options[selectedIndex]; + if (textContent !== null) { + content = textContent; + } + } } break; - default: - content = `${element.value}`; + case 'INPUT': + { + content = /** @type {HTMLInputElement} */ (element).value; + } break; } diff --git a/ext/js/dom/text-source-range.js b/ext/js/dom/text-source-range.js index d5e70052..fd09fdda 100644 --- a/ext/js/dom/text-source-range.js +++ b/ext/js/dom/text-source-range.js @@ -18,7 +18,6 @@ import {DocumentUtil} from './document-util.js'; import {DOMTextScanner} from './dom-text-scanner.js'; -import {TextSourceElement} from './text-source-element.js'; /** * This class represents a text source that comes from text nodes in the document. @@ -36,26 +35,33 @@ export class TextSourceRange { * @param {?Element} imposterElement The temporary imposter element. * @param {?Element} imposterSourceElement The source element which the imposter is imitating. * Must not be `null` if imposterElement is specified. - * @param {?Rect[]} cachedRects A set of cached `DOMRect`s representing the rects of the text source, + * @param {?DOMRect[]} cachedRects A set of cached `DOMRect`s representing the rects of the text source, * which can be used after the imposter element is removed from the page. * Must not be `null` if imposterElement is specified. - * @param {?Rect} cachedSourceRect A cached `DOMRect` representing the rect of the `imposterSourceElement`, + * @param {?DOMRect} cachedSourceRect A cached `DOMRect` representing the rect of the `imposterSourceElement`, * which can be used after the imposter element is removed from the page. * Must not be `null` if imposterElement is specified. */ constructor(range, rangeStartOffset, content, imposterElement, imposterSourceElement, cachedRects, cachedSourceRect) { + /** @type {Range} */ this._range = range; + /** @type {number} */ this._rangeStartOffset = rangeStartOffset; + /** @type {string} */ this._content = content; + /** @type {?Element} */ this._imposterElement = imposterElement; + /** @type {?Element} */ this._imposterSourceElement = imposterSourceElement; + /** @type {?DOMRect[]} */ this._cachedRects = cachedRects; + /** @type {?DOMRect} */ this._cachedSourceRect = cachedSourceRect; } /** * Gets the type name of this instance. - * @type {string} + * @type {'range'} */ get type() { return 'range'; @@ -71,7 +77,7 @@ export class TextSourceRange { /** * The starting offset for the range. - * @type {Range} + * @type {number} */ get rangeStartOffset() { return this._rangeStartOffset; @@ -169,12 +175,12 @@ export class TextSourceRange { /** * Gets writing mode that is used for this element. * See: https://developer.mozilla.org/en-US/docs/Web/CSS/writing-mode. - * @returns {string} The rects. + * @returns {import('document-util').NormalizedWritingMode} The writing mode. */ getWritingMode() { let node = this._isImposterDisconnected() ? this._imposterSourceElement : this._range.startContainer; if (node !== null && node.nodeType !== Node.ELEMENT_NODE) { node = node.parentElement; } - return DocumentUtil.getElementWritingMode(node); + return DocumentUtil.getElementWritingMode(/** @type {?Element} */ (node)); } /** @@ -183,6 +189,7 @@ export class TextSourceRange { select() { if (this._imposterElement !== null) { return; } const selection = window.getSelection(); + if (selection === null) { return; } selection.removeAllRanges(); selection.addRange(this._range); } @@ -193,12 +200,13 @@ export class TextSourceRange { deselect() { if (this._imposterElement !== null) { return; } const selection = window.getSelection(); + if (selection === null) { return; } selection.removeAllRanges(); } /** * Checks whether another text source has the same starting point. - * @param {TextSourceElement|TextSourceRange} other The other source to test. + * @param {import('text-source').TextSource} other The other source to test. * @returns {boolean} `true` if the starting points are equivalent, `false` otherwise. * @throws {Error} An exception can be thrown if `Range.compareBoundaryPoints` fails, * which shouldn't happen, but the handler is kept in case of unexpected errors. @@ -220,7 +228,7 @@ export class TextSourceRange { try { return this._range.compareBoundaryPoints(Range.START_TO_START, other.range) === 0; } catch (e) { - if (e.name === 'WrongDocumentError') { + if (e instanceof Error && e.name === 'WrongDocumentError') { // This can happen with shadow DOMs if the ranges are in different documents. return false; } @@ -269,9 +277,17 @@ export class TextSourceRange { /** * Gets the cached rects for a disconnected imposter element. - * @returns {Rect[]} The rects for the element. + * @returns {DOMRect[]} The rects for the element. + * @throws {Error} */ _getCachedRects() { + if ( + this._cachedRects === null || + this._cachedSourceRect === null || + this._imposterSourceElement === null + ) { + throw new Error('Cached rects not valid for this instance'); + } const sourceRect = DocumentUtil.convertRectZoomCoordinates(this._imposterSourceElement.getBoundingClientRect(), this._imposterSourceElement); return DocumentUtil.offsetDOMRects( this._cachedRects, diff --git a/ext/js/extension/environment.js b/ext/js/extension/environment.js index 8c43f1a0..48a1a92c 100644 --- a/ext/js/extension/environment.js +++ b/ext/js/extension/environment.js @@ -18,18 +18,29 @@ export class Environment { constructor() { + /** @type {?import('environment').Info} */ this._cachedEnvironmentInfo = null; } + /** + * @returns {Promise<void>} + */ async prepare() { this._cachedEnvironmentInfo = await this._loadEnvironmentInfo(); } + /** + * @returns {import('environment').Info} + * @throws {Error} + */ getInfo() { if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); } return this._cachedEnvironmentInfo; } + /** + * @returns {Promise<import('environment').Info>} + */ async _loadEnvironmentInfo() { const os = await this._getOperatingSystem(); const browser = await this._getBrowser(os); @@ -40,6 +51,9 @@ export class Environment { }; } + /** + * @returns {Promise<import('environment').OperatingSystem>} + */ async _getOperatingSystem() { try { const {os} = await this._getPlatformInfo(); @@ -52,6 +66,9 @@ export class Environment { return 'unknown'; } + /** + * @returns {Promise<chrome.runtime.PlatformInfo>} + */ _getPlatformInfo() { return new Promise((resolve, reject) => { chrome.runtime.getPlatformInfo((result) => { @@ -65,6 +82,10 @@ export class Environment { }); } + /** + * @param {import('environment').OperatingSystem} os + * @returns {Promise<import('environment').Browser>} + */ async _getBrowser(os) { try { if (chrome.runtime.getURL('/').startsWith('ms-browser-extension://')) { @@ -89,6 +110,9 @@ export class Environment { } } + /** + * @returns {boolean}; + */ _isSafari() { const {vendor, userAgent} = navigator; return ( diff --git a/ext/js/general/cache-map.js b/ext/js/general/cache-map.js index 1ee385bf..cc706380 100644 --- a/ext/js/general/cache-map.js +++ b/ext/js/general/cache-map.js @@ -18,6 +18,7 @@ /** + * @template K,V * Class which caches a map of values, keeping the most recently accessed values. */ export class CacheMap { @@ -35,9 +36,13 @@ export class CacheMap { throw new Error('Invalid maxCount'); } + /** @type {number} */ this._maxSize = maxSize; + /** @type {Map<K, import('cache-map').Node<K, V>>} */ this._map = new Map(); + /** @type {import('cache-map').Node<K, V>} */ this._listFirst = this._createNode(null, null); + /** @type {import('cache-map').Node<K, V>} */ this._listLast = this._createNode(null, null); this._resetEndNodes(); } @@ -60,7 +65,7 @@ export class CacheMap { /** * Returns whether or not an element exists at the given key. - * @param {*} key The key of the element. + * @param {K} key The key of the element. * @returns {boolean} `true` if an element with the specified key exists, `false` otherwise. */ has(key) { @@ -69,20 +74,20 @@ export class CacheMap { /** * Gets an element at the given key, if it exists. Otherwise, returns undefined. - * @param {*} key The key of the element. - * @returns {*} The existing value at the key, if any; `undefined` otherwise. + * @param {K} key The key of the element. + * @returns {V|undefined} The existing value at the key, if any; `undefined` otherwise. */ get(key) { const node = this._map.get(key); if (typeof node === 'undefined') { return void 0; } this._updateRecency(node); - return node.value; + return /** @type {V} */ (node.value); } /** * Sets a value at a given key. - * @param {*} key The key of the element. - * @param {*} value The value to store in the cache. + * @param {K} key The key of the element. + * @param {V} value The value to store in the cache. */ set(key, value) { let node = this._map.get(key); @@ -98,9 +103,9 @@ export class CacheMap { // Remove for (let removeCount = this._map.size - this._maxSize; removeCount > 0; --removeCount) { - node = this._listLast.previous; + node = /** @type {import('cache-map').Node<K, V>} */ (this._listLast.previous); this._removeNode(node); - this._map.delete(node.key); + this._map.delete(/** @type {K} */ (node.key)); } } } @@ -115,28 +120,46 @@ export class CacheMap { // Private + /** + * @param {import('cache-map').Node<K, V>} node + */ _updateRecency(node) { this._removeNode(node); this._addNode(node, this._listFirst); } + /** + * @param {?K} key + * @param {?V} value + * @returns {import('cache-map').Node<K, V>} + */ _createNode(key, value) { return {key, value, previous: null, next: null}; } + /** + * @param {import('cache-map').Node<K, V>} node + * @param {import('cache-map').Node<K, V>} previous + */ _addNode(node, previous) { const next = previous.next; node.next = next; node.previous = previous; previous.next = node; - next.previous = node; + /** @type {import('cache-map').Node<K, V>} */ (next).previous = node; } + /** + * @param {import('cache-map').Node<K, V>} node + */ _removeNode(node) { - node.next.previous = node.previous; - node.previous.next = node.next; + /** @type {import('cache-map').Node<K, V>} */ (node.next).previous = node.previous; + /** @type {import('cache-map').Node<K, V>} */ (node.previous).next = node.next; } + /** + * @returns {void} + */ _resetEndNodes() { this._listFirst.next = this._listLast; this._listLast.previous = this._listFirst; diff --git a/ext/js/general/object-property-accessor.js b/ext/js/general/object-property-accessor.js index b8309ed2..d818c9d1 100644 --- a/ext/js/general/object-property-accessor.js +++ b/ext/js/general/object-property-accessor.js @@ -22,9 +22,10 @@ export class ObjectPropertyAccessor { /** * Create a new accessor for a specific object. - * @param {object} target The object which the getter and mutation methods are applied to. + * @param {unknown} target The object which the getter and mutation methods are applied to. */ constructor(target) { + /** @type {unknown} */ this._target = target; } @@ -33,7 +34,7 @@ export class ObjectPropertyAccessor { * @param {(string|number)[]} pathArray The path to the property on the target object. * @param {number} [pathLength] How many parts of the pathArray to use. * This parameter is optional and defaults to the length of pathArray. - * @returns {*} The value found at the path. + * @returns {unknown} The value found at the path. * @throws {Error} An error is thrown if pathArray is not valid for the target object. */ get(pathArray, pathLength) { @@ -44,7 +45,7 @@ export class ObjectPropertyAccessor { if (!ObjectPropertyAccessor.hasProperty(target, key)) { throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray.slice(0, i + 1))}`); } - target = target[key]; + target = /** @type {import('core').SerializableObject} */ (target)[key]; } return target; } @@ -52,7 +53,7 @@ export class ObjectPropertyAccessor { /** * Sets the value at the specified path. * @param {(string|number)[]} pathArray The path to the property on the target object. - * @param {*} value The value to assign to the property. + * @param {unknown} value The value to assign to the property. * @throws {Error} An error is thrown if pathArray is not valid for the target object. */ set(pathArray, value) { @@ -65,7 +66,7 @@ export class ObjectPropertyAccessor { throw new Error(`Invalid path: ${ObjectPropertyAccessor.getPathString(pathArray)}`); } - target[key] = value; + /** @type {import('core').SerializableObject} */ (target)[key] = value; } /** @@ -87,7 +88,7 @@ export class ObjectPropertyAccessor { throw new Error('Invalid type'); } - delete target[key]; + delete /** @type {import('core').SerializableObject} */ (target)[key]; } /** @@ -110,16 +111,16 @@ export class ObjectPropertyAccessor { const key2 = pathArray2[ii2]; if (!ObjectPropertyAccessor.isValidPropertyType(target2, key2)) { throw new Error(`Invalid path 2: ${ObjectPropertyAccessor.getPathString(pathArray2)}`); } - const value1 = target1[key1]; - const value2 = target2[key2]; + const value1 = /** @type {import('core').SerializableObject} */ (target1)[key1]; + const value2 = /** @type {import('core').SerializableObject} */ (target2)[key2]; - target1[key1] = value2; + /** @type {import('core').SerializableObject} */ (target1)[key1] = value2; try { - target2[key2] = value1; + /** @type {import('core').SerializableObject} */ (target2)[key2] = value1; } catch (e) { // Revert try { - target1[key1] = value1; + /** @type {import('core').SerializableObject} */ (target1)[key1] = value1; } catch (e2) { // NOP } @@ -178,7 +179,7 @@ export class ObjectPropertyAccessor { let value = ''; let escaped = false; for (const c of pathString) { - const v = c.codePointAt(0); + const v = /** @type {number} */ (c.codePointAt(0)); switch (state) { case 'empty': // Empty case 'id-start': // Expecting identifier start @@ -288,7 +289,7 @@ export class ObjectPropertyAccessor { /** * Checks whether an object or array has the specified property. - * @param {*} object The object to test. + * @param {unknown} object The object to test. * @param {string|number} property The property to check for existence. * This value should be a string if the object is a non-array object. * For arrays, it should be an integer. @@ -317,7 +318,7 @@ export class ObjectPropertyAccessor { /** * Checks whether a property is valid for the given object - * @param {object} object The object to test. + * @param {unknown} object The object to test. * @param {string|number} property The property to check for existence. * @returns {boolean} `true` if the property is correct for the given object type, otherwise `false`. * For arrays, this means that the property should be a positive integer. diff --git a/ext/js/general/regex-util.js b/ext/js/general/regex-util.js index 298189d4..62248968 100644 --- a/ext/js/general/regex-util.js +++ b/ext/js/general/regex-util.js @@ -25,7 +25,7 @@ export class RegexUtil { * Applies string.replace using a regular expression and replacement string as arguments. * A source map of the changes is also maintained. * @param {string} text A string of the text to replace. - * @param {TextSourceMap} sourceMap An instance of `TextSourceMap` which corresponds to `text`. + * @param {import('./text-source-map.js').TextSourceMap} sourceMap An instance of `TextSourceMap` which corresponds to `text`. * @param {RegExp} pattern A regular expression to use as the replacement. * @param {string} replacement A replacement string that follows the format of the standard * JavaScript regular expression replacement string. @@ -61,7 +61,7 @@ export class RegexUtil { * Applies the replacement string for a given regular expression match. * @param {string} replacement The replacement string that follows the format of the standard * JavaScript regular expression replacement string. - * @param {object} match A match object returned from RegExp.match. + * @param {RegExpMatchArray} match A match object returned from RegExp.match. * @returns {string} A new string with the pattern replacement applied. */ static applyMatchReplacement(replacement, match) { @@ -79,11 +79,13 @@ export class RegexUtil { return groups[g2]; } } else { + let {index} = match; + if (typeof index !== 'number') { index = 0; } switch (g0) { case '$': return '$'; case '&': return match[0]; - case '`': return replacement.substring(0, match.index); - case '\'': return replacement.substring(match.index + g0.length); + case '`': return replacement.substring(0, index); + case '\'': return replacement.substring(index + g0.length); } } return g0; diff --git a/ext/js/general/task-accumulator.js b/ext/js/general/task-accumulator.js index cae58b94..cb136908 100644 --- a/ext/js/general/task-accumulator.js +++ b/ext/js/general/task-accumulator.js @@ -18,25 +18,46 @@ import {log} from '../core.js'; +/** + * @template K,V + */ export class TaskAccumulator { + /** + * @param {(tasks: [key: ?K, task: import('task-accumulator').Task<V>][]) => Promise<void>} runTasks + */ constructor(runTasks) { + /** @type {?Promise<void>} */ this._deferPromise = null; + /** @type {?Promise<void>} */ this._activePromise = null; + /** @type {import('task-accumulator').Task<V>[]} */ this._tasks = []; + /** @type {import('task-accumulator').Task<V>[]} */ this._tasksActive = []; + /** @type {Map<K, import('task-accumulator').Task<V>>} */ this._uniqueTasks = new Map(); + /** @type {Map<K, import('task-accumulator').Task<V>>} */ this._uniqueTasksActive = new Map(); + /** @type {() => Promise<void>} */ this._runTasksBind = this._runTasks.bind(this); + /** @type {() => void} */ this._tasksCompleteBind = this._tasksComplete.bind(this); - this._runTasks = runTasks; + /** @type {(tasks: [key: ?K, task: import('task-accumulator').Task<V>][]) => Promise<void>} */ + this._runTasksCallback = runTasks; } + /** + * @param {?K} key + * @param {V} data + * @returns {Promise<void>} + */ enqueue(key, data) { if (this._deferPromise === null) { const promise = this._activePromise !== null ? this._activePromise : Promise.resolve(); this._deferPromise = promise.then(this._runTasksBind); } + /** @type {import('task-accumulator').Task<V>} */ const task = {data, stale: false}; if (key !== null) { const activeTaskInfo = this._uniqueTasksActive.get(key); @@ -52,6 +73,9 @@ export class TaskAccumulator { return this._deferPromise; } + /** + * @returns {Promise<void>} + */ _runTasks() { this._deferPromise = null; @@ -64,18 +88,28 @@ export class TaskAccumulator { return this._activePromise; } + /** + * @returns {Promise<void>} + */ async _runTasksAsync() { try { - const allTasks = [ - ...this._tasksActive.map((taskInfo) => [null, taskInfo]), - ...this._uniqueTasksActive.entries() - ]; - await this._runTasks(allTasks); + /** @type {[key: ?K, task: import('task-accumulator').Task<V>][]} */ + const allTasks = []; + for (const taskInfo of this._tasksActive) { + allTasks.push([null, taskInfo]); + } + for (const [key, taskInfo] of this._uniqueTasksActive) { + allTasks.push([key, taskInfo]); + } + await this._runTasksCallback(allTasks); } catch (e) { log.error(e); } } + /** + * @returns {void} + */ _tasksComplete() { this._tasksActive.length = 0; this._uniqueTasksActive.clear(); diff --git a/ext/js/general/text-source-map.js b/ext/js/general/text-source-map.js index 6a136451..b03f6eb2 100644 --- a/ext/js/general/text-source-map.js +++ b/ext/js/general/text-source-map.js @@ -17,15 +17,26 @@ */ export class TextSourceMap { + /** + * @param {string} source + * @param {number[]|null} [mapping=null] + */ constructor(source, mapping=null) { + /** @type {string} */ this._source = source; + /** @type {?number[]} */ this._mapping = (mapping !== null ? TextSourceMap.normalizeMapping(mapping) : null); } + /** @type {string} */ get source() { return this._source; } + /** + * @param {unknown} other + * @returns {boolean} + */ equals(other) { if (this === other) { return true; @@ -61,6 +72,10 @@ export class TextSourceMap { return true; } + /** + * @param {number} finalLength + * @returns {number} + */ getSourceLength(finalLength) { const mapping = this._mapping; if (mapping === null) { @@ -74,6 +89,10 @@ export class TextSourceMap { return sourceLength; } + /** + * @param {number} index + * @param {number} count + */ combine(index, count) { if (count <= 0) { return; } @@ -89,6 +108,10 @@ export class TextSourceMap { this._mapping[index] = sum; } + /** + * @param {number} index + * @param {number[]} items + */ insert(index, ...items) { if (this._mapping === null) { this._mapping = TextSourceMap.createMapping(this._source); @@ -97,14 +120,25 @@ export class TextSourceMap { this._mapping.splice(index, 0, ...items); } + /** + * @returns {?number[]} + */ getMappingCopy() { return this._mapping !== null ? [...this._mapping] : null; } + /** + * @param {string} text + * @returns {number[]} + */ static createMapping(text) { return new Array(text.length).fill(1); } + /** + * @param {number[]} mapping + * @returns {number[]} + */ static normalizeMapping(mapping) { const result = []; for (const value of mapping) { diff --git a/ext/js/input/hotkey-handler.js b/ext/js/input/hotkey-handler.js index 2fd35a5c..f1512b8f 100644 --- a/ext/js/input/hotkey-handler.js +++ b/ext/js/input/hotkey-handler.js @@ -22,28 +22,25 @@ import {yomitan} from '../yomitan.js'; /** * Class which handles hotkey events and actions. + * @augments EventDispatcher<import('hotkey-handler').EventType> */ export class HotkeyHandler extends EventDispatcher { /** - * Information describing a hotkey. - * @typedef {object} HotkeyDefinition - * @property {string} action A string indicating which action to perform. - * @property {string} key A keyboard key code indicating which key needs to be pressed. - * @property {string[]} modifiers An array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`. - * @property {string[]} scopes An array of scopes for which the hotkey is valid. If this array does not contain `this.scope`, the hotkey will not be registered. - * @property {boolean} enabled A boolean indicating whether the hotkey is currently enabled. - */ - - /** * Creates a new instance of the class. */ constructor() { super(); + /** @type {Map<string, (argument: unknown) => (boolean|void)>} */ this._actions = new Map(); + /** @type {Map<string, import('hotkey-handler').HotkeyHandlers>} */ this._hotkeys = new Map(); + /** @type {Map<import('settings').InputsHotkeyScope, import('settings').InputsHotkeyOptions[]>} */ this._hotkeyRegistrations = new Map(); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {boolean} */ this._isPrepared = false; + /** @type {boolean} */ this._hasEventListeners = false; } @@ -60,7 +57,7 @@ export class HotkeyHandler extends EventDispatcher { /** * Registers a set of actions that this hotkey handler supports. - * @param {*[][]} actions An array of `[name, handler]` entries, where `name` is a string and `handler` is a function. + * @param {[name: string, handler: (argument: unknown) => (boolean|void)][]} actions An array of `[name, handler]` entries, where `name` is a string and `handler` is a function. */ registerActions(actions) { for (const [name, handler] of actions) { @@ -70,8 +67,8 @@ export class HotkeyHandler extends EventDispatcher { /** * Registers a set of hotkeys for a given scope. - * @param {string} scope The scope that the hotkey definitions must be for in order to be activated. - * @param {HotkeyDefinition[]} hotkeys An array of hotkey definitions. + * @param {import('settings').InputsHotkeyScope} scope The scope that the hotkey definitions must be for in order to be activated. + * @param {import('settings').InputsHotkeyOptions[]} hotkeys An array of hotkey definitions. */ registerHotkeys(scope, hotkeys) { let registrations = this._hotkeyRegistrations.get(scope); @@ -85,7 +82,7 @@ export class HotkeyHandler extends EventDispatcher { /** * Removes all registered hotkeys for a given scope. - * @param {string} scope The scope that the hotkey definitions were registered in. + * @param {import('settings').InputsHotkeyScope} scope The scope that the hotkey definitions were registered in. */ clearHotkeys(scope) { const registrations = this._hotkeyRegistrations.get(scope); @@ -98,8 +95,8 @@ export class HotkeyHandler extends EventDispatcher { /** * Assigns a set of hotkeys for a given scope. This is an optimized shorthand for calling * `clearHotkeys`, then calling `registerHotkeys`. - * @param {string} scope The scope that the hotkey definitions must be for in order to be activated. - * @param {HotkeyDefinition[]} hotkeys An array of hotkey definitions. + * @param {import('settings').InputsHotkeyScope} scope The scope that the hotkey definitions must be for in order to be activated. + * @param {import('settings').InputsHotkeyOptions[]} hotkeys An array of hotkey definitions. */ setHotkeys(scope, hotkeys) { let registrations = this._hotkeyRegistrations.get(scope); @@ -109,14 +106,24 @@ export class HotkeyHandler extends EventDispatcher { } else { registrations.length = 0; } - registrations.push(...hotkeys); + for (const {action, argument, key, modifiers, scopes, enabled} of hotkeys) { + registrations.push({ + action, + argument, + key, + modifiers: [...modifiers], + scopes: [...scopes], + enabled + }); + } this._updateHotkeyRegistrations(); } /** * Adds a single event listener to a specific event. - * @param {string} eventName The string representing the event's name. - * @param {Function} callback The event listener callback to add. + * @template [TEventDetails=unknown] + * @param {import('hotkey-handler').EventType} eventName The string representing the event's name. + * @param {(details: TEventDetails) => void} callback The event listener callback to add. * @returns {void} */ on(eventName, callback) { @@ -128,8 +135,9 @@ export class HotkeyHandler extends EventDispatcher { /** * Removes a single event listener from a specific event. - * @param {string} eventName The string representing the event's name. - * @param {Function} callback The event listener callback to add. + * @template [TEventDetails=unknown] + * @param {import('hotkey-handler').EventType} eventName The string representing the event's name. + * @param {(details: TEventDetails) => void} callback The event listener callback to add. * @returns {boolean} `true` if the callback was removed, `false` otherwise. */ off(eventName, callback) { @@ -142,37 +150,50 @@ export class HotkeyHandler extends EventDispatcher { /** * Attempts to simulate an action for a given combination of key and modifiers. * @param {string} key A keyboard key code indicating which key needs to be pressed. - * @param {string[]} modifiers An array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`. + * @param {import('input').ModifierKey[]} modifiers An array of keyboard modifiers which also need to be pressed. Supports: `'alt', 'ctrl', 'shift', 'meta'`. * @returns {boolean} `true` if an action was performed, `false` otherwise. */ simulate(key, modifiers) { const hotkeyInfo = this._hotkeys.get(key); return ( typeof hotkeyInfo !== 'undefined' && - this._invokeHandlers(modifiers, hotkeyInfo) + this._invokeHandlers(modifiers, hotkeyInfo, key) ); } // Message handlers + /** + * @param {{key: string, modifiers: import('input').ModifierKey[]}} details + * @returns {boolean} + */ _onMessageForwardHotkey({key, modifiers}) { return this.simulate(key, modifiers); } // Private - _onKeyDown(e) { - const hotkeyInfo = this._hotkeys.get(e.code); + /** + * @param {KeyboardEvent} event + */ + _onKeyDown(event) { + const hotkeyInfo = this._hotkeys.get(event.code); if (typeof hotkeyInfo !== 'undefined') { - const eventModifiers = DocumentUtil.getActiveModifiers(e); - if (this._invokeHandlers(eventModifiers, hotkeyInfo, e.key)) { - e.preventDefault(); + const eventModifiers = DocumentUtil.getActiveModifiers(event); + if (this._invokeHandlers(eventModifiers, hotkeyInfo, event.key)) { + event.preventDefault(); return; } } - this.trigger('keydownNonHotkey', e); + this.trigger('keydownNonHotkey', event); } + /** + * @param {import('input').ModifierKey[]} modifiers + * @param {import('hotkey-handler').HotkeyHandlers} hotkeyInfo + * @param {string} key + * @returns {boolean} + */ _invokeHandlers(modifiers, hotkeyInfo, key) { for (const {modifiers: handlerModifiers, action, argument} of hotkeyInfo.handlers) { if (!this._areSame(handlerModifiers, modifiers) || !this._isHotkeyPermitted(modifiers, key)) { continue; } @@ -189,6 +210,11 @@ export class HotkeyHandler extends EventDispatcher { return false; } + /** + * @param {Set<unknown>} set + * @param {unknown[]} array + * @returns {boolean} + */ _areSame(set, array) { if (set.size !== array.length) { return false; } for (const value of array) { @@ -199,6 +225,9 @@ export class HotkeyHandler extends EventDispatcher { return true; } + /** + * @returns {void} + */ _updateHotkeyRegistrations() { if (this._hotkeys.size === 0 && this._hotkeyRegistrations.size === 0) { return; } @@ -219,10 +248,16 @@ export class HotkeyHandler extends EventDispatcher { this._updateEventHandlers(); } + /** + * @returns {void} + */ _updateHasEventListeners() { this._hasEventListeners = this.hasListeners('keydownNonHotkey'); } + /** + * @returns {void} + */ _updateEventHandlers() { if (this._isPrepared && (this._hotkeys.size > 0 || this._hasEventListeners)) { if (this._eventListeners.size !== 0) { return; } @@ -232,6 +267,11 @@ export class HotkeyHandler extends EventDispatcher { } } + /** + * @param {import('input').ModifierKey[]} modifiers + * @param {string} key + * @returns {boolean} + */ _isHotkeyPermitted(modifiers, key) { return !( (modifiers.length === 0 || (modifiers.length === 1 && modifiers[0] === 'shift')) && @@ -240,6 +280,10 @@ export class HotkeyHandler extends EventDispatcher { ); } + /** + * @param {string} key + * @returns {boolean} + */ _isKeyCharacterInput(key) { return key.length === 1; } diff --git a/ext/js/input/hotkey-help-controller.js b/ext/js/input/hotkey-help-controller.js index 51ec8fac..4a3c0264 100644 --- a/ext/js/input/hotkey-help-controller.js +++ b/ext/js/input/hotkey-help-controller.js @@ -22,21 +22,31 @@ import {HotkeyUtil} from './hotkey-util.js'; export class HotkeyHelpController { constructor() { + /** @type {HotkeyUtil} */ this._hotkeyUtil = new HotkeyUtil(); - this._localActionHotseys = new Map(); + /** @type {Map<string, string>} */ + this._localActionHotkeys = new Map(); + /** @type {Map<string, string>} */ this._globalActionHotkeys = new Map(); + /** @type {RegExp} */ this._replacementPattern = /\{0\}/g; } + /** + * @returns {Promise<void>} + */ async prepare() { const {platform: {os}} = await yomitan.api.getEnvironmentInfo(); this._hotkeyUtil.os = os; await this._setupGlobalCommands(this._globalActionHotkeys); } + /** + * @param {import('settings').ProfileOptions} options + */ setOptions(options) { const hotkeys = options.inputs.hotkeys; - const hotkeyMap = this._localActionHotseys; + const hotkeyMap = this._localActionHotkeys; hotkeyMap.clear(); for (const {enabled, action, key, modifiers} of hotkeys) { if (!enabled || key === null || action === '' || hotkeyMap.has(action)) { continue; } @@ -44,28 +54,25 @@ export class HotkeyHelpController { } } + /** + * @param {ParentNode} node + */ setupNode(node) { - const globalPrexix = 'global:'; const replacementPattern = this._replacementPattern; - for (const node2 of node.querySelectorAll('[data-hotkey]')) { - const data = JSON.parse(node2.dataset.hotkey); - let [action, attributes, values] = data; - if (!Array.isArray(attributes)) { attributes = [attributes]; } + for (const node2 of /** @type {NodeListOf<HTMLElement>} */ (node.querySelectorAll('[data-hotkey]'))) { + const info = this._getNodeInfo(node2); + if (info === null) { continue; } + const {action, global, attributes, values, defaultAttributeValues} = info; const multipleValues = Array.isArray(values); - - const actionIsGlobal = action.startsWith(globalPrexix); - if (actionIsGlobal) { action = action.substring(globalPrexix.length); } - - const defaultAttributeValues = this._getDefaultAttributeValues(node2, data, attributes); - - const hotkey = (actionIsGlobal ? this._globalActionHotkeys : this._localActionHotseys).get(action); - + const hotkey = (global ? this._globalActionHotkeys : this._localActionHotkeys).get(action); for (let i = 0, ii = attributes.length; i < ii; ++i) { const attribute = attributes[i]; - let value = null; + let value; if (typeof hotkey !== 'undefined') { - value = (multipleValues ? values[i] : values); - value = value.replace(replacementPattern, hotkey); + value = /** @type {unknown} */ (multipleValues ? values[i] : values); + if (typeof value === 'string') { + value = value.replace(replacementPattern, hotkey); + } } else { value = defaultAttributeValues[i]; } @@ -81,6 +88,9 @@ export class HotkeyHelpController { // Private + /** + * @param {Map<string, string>} commandMap + */ async _setupGlobalCommands(commandMap) { const commands = await new Promise((resolve, reject) => { if (!(isObject(chrome.commands) && typeof chrome.commands.getAll === 'function')) { @@ -104,14 +114,23 @@ export class HotkeyHelpController { const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut); commandMap.set(name, this._hotkeyUtil.getInputDisplayValue(key, modifiers)); } - return commandMap; } + /** + * @param {HTMLElement} node + * @param {unknown[]} data + * @param {string[]} attributes + * @returns {unknown[]} + */ _getDefaultAttributeValues(node, data, attributes) { if (data.length > 3) { - return data[3]; + const result = data[3]; + if (Array.isArray(result)) { + return result; + } } + /** @type {(?string)[]} */ const defaultAttributeValues = []; for (let i = 0, ii = attributes.length; i < ii; ++i) { const attribute = attributes[i]; @@ -122,4 +141,37 @@ export class HotkeyHelpController { node.dataset.hotkey = JSON.stringify(data); return defaultAttributeValues; } + + /** + * @param {HTMLElement} node + * @returns {?{action: string, global: boolean, attributes: string[], values: unknown, defaultAttributeValues: unknown[]}} + */ + _getNodeInfo(node) { + const {hotkey} = node.dataset; + if (typeof hotkey !== 'string') { return null; } + const data = /** @type {unknown} */ (JSON.parse(hotkey)); + if (!Array.isArray(data)) { return null; } + const [action, attributes, values] = data; + if (typeof action !== 'string') { return null; } + /** @type {string[]} */ + const attributesArray = []; + if (Array.isArray(attributes)) { + for (const item of attributes) { + if (typeof item !== 'string') { continue; } + attributesArray.push(item); + } + } else if (typeof attributes === 'string') { + attributesArray.push(attributes); + } + const defaultAttributeValues = this._getDefaultAttributeValues(node, data, attributesArray); + const globalPrexix = 'global:'; + const global = action.startsWith(globalPrexix); + return { + action: global ? action.substring(globalPrexix.length) : action, + global, + attributes, + values, + defaultAttributeValues + }; + } } diff --git a/ext/js/input/hotkey-util.js b/ext/js/input/hotkey-util.js index e23849e0..10328924 100644 --- a/ext/js/input/hotkey-util.js +++ b/ext/js/input/hotkey-util.js @@ -22,19 +22,25 @@ export class HotkeyUtil { /** * Creates a new instance. - * @param {?string} os The operating system for this instance. + * @param {?import('environment').OperatingSystem} os The operating system for this instance. */ constructor(os=null) { + /** @type {?import('environment').OperatingSystem} */ this._os = os; + /** @type {string} */ this._inputSeparator = ' + '; + /** @type {Map<import('input').Modifier, string>} */ this._modifierKeyNames = new Map(); + /** @type {RegExp} */ this._mouseInputNamePattern = /^mouse(\d+)$/; + /** @type {Map<import('input').Modifier, number>} */ this._modifierPriorities = new Map([ ['meta', -4], ['ctrl', -3], ['alt', -2], ['shift', -1] ]); + /** @type {Intl.Collator} */ this._stringComparer = new Intl.Collator('en-US'); // Invariant locale this._updateModifierKeyNames(); @@ -43,7 +49,7 @@ export class HotkeyUtil { /** * Gets the operating system for this instance. * The operating system is used to display system-localized modifier key names. - * @type {?string} + * @type {?import('environment').OperatingSystem} */ get os() { return this._os; @@ -51,7 +57,7 @@ export class HotkeyUtil { /** * Sets the operating system for this instance. - * @param {?string} value The value to assign. + * @param {?import('environment').OperatingSystem} value The value to assign. * Valid values are: win, mac, linux, openbsd, cros, android. */ set os(value) { @@ -63,7 +69,7 @@ export class HotkeyUtil { /** * Gets a display string for a key and a set of modifiers. * @param {?string} key The key code string, or `null` for no key. - * @param {string[]} modifiers An array of modifiers. + * @param {import('input').Modifier[]} modifiers An array of modifiers. * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. * @returns {string} A user-friendly string for the combination of key and modifiers. */ @@ -88,7 +94,7 @@ export class HotkeyUtil { /** * Gets a display string for a single modifier. - * @param {string} modifier A string representing a modifier. + * @param {import('input').Modifier} modifier A string representing a modifier. * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. * @returns {string} A user-friendly string for the modifier. */ @@ -116,9 +122,9 @@ export class HotkeyUtil { /** * Gets a display string for a single modifier. - * @param {string} modifier A string representing a modifier. + * @param {import('input').Modifier} modifier A string representing a modifier. * Valid values are: ctrl, alt, shift, meta, or mouseN, where N is an integer. - * @returns {'mouse'|'key'} `'mouse'` if the modifier represents a mouse button, `'key'` otherwise. + * @returns {import('input').ModifierType} `'mouse'` if the modifier represents a mouse button, `'key'` otherwise. */ getModifierType(modifier) { return (this._mouseInputNamePattern.test(modifier) ? 'mouse' : 'key'); @@ -126,18 +132,22 @@ export class HotkeyUtil { /** * Converts an extension command string into a standard input. - * @param {string} command An extension command string. - * @returns {{key: ?string, modifiers: string[]}} An object `{key, modifiers}`, where key is a string (or `null`) representing the key, and modifiers is an array of modifier keys. + * @param {string|undefined} command An extension command string. + * @returns {{key: ?string, modifiers: import('input').ModifierKey[]}} An object `{key, modifiers}`, where key is a string (or `null`) representing the key, and modifiers is an array of modifier keys. */ convertCommandToInput(command) { let key = null; + /** @type {Set<import('input').ModifierKey>} */ const modifiers = new Set(); if (typeof command === 'string' && command.length > 0) { const parts = command.split('+'); const ii = parts.length - 1; key = this._convertCommandKeyToInputKey(parts[ii]); for (let i = 0; i < ii; ++i) { - modifiers.add(this._convertCommandModifierToInputModifier(parts[i])); + const modifier = this._convertCommandModifierToInputModifier(parts[i]); + if (modifier !== null) { + modifiers.add(modifier); + } } } return {key, modifiers: this.sortModifiers([...modifiers])}; @@ -146,7 +156,7 @@ export class HotkeyUtil { /** * Gets a command string for a specified input. * @param {?string} key The key code string, or `null` for no key. - * @param {string[]} modifiers An array of modifier keys. + * @param {import('input').Modifier[]} modifiers An array of modifier keys. * Valid values are: ctrl, alt, shift, meta. * @returns {string} An extension command string representing the input. */ @@ -170,10 +180,11 @@ export class HotkeyUtil { } /** + * @template {import('input').Modifier} TModifier * Sorts an array of modifiers. - * @param {string[]} modifiers An array of modifiers. + * @param {TModifier[]} modifiers An array of modifiers. * Valid values are: ctrl, alt, shift, meta. - * @returns {string[]} A sorted array of modifiers. The array instance is the same as the input array. + * @returns {TModifier[]} A sorted array of modifiers. The array instance is the same as the input array. */ sortModifiers(modifiers) { const pattern = this._mouseInputNamePattern; @@ -181,10 +192,12 @@ export class HotkeyUtil { const stringComparer = this._stringComparer; const count = modifiers.length; + /** @type {[modifier: TModifier, isMouse: 0|1, priority: number, index: number][]} */ const modifierInfos = []; for (let i = 0; i < count; ++i) { const modifier = modifiers[i]; const match = pattern.exec(modifier); + /** @type {[modifier: TModifier, isMouse: 0|1, priority: number, index: number]} */ let info; if (match !== null) { info = [modifier, 1, Number.parseInt(match[1], 10), i]; @@ -219,6 +232,10 @@ export class HotkeyUtil { // Private + /** + * @param {?import('environment').OperatingSystem} os + * @returns {[modifier: import('input').ModifierKey, label: string][]} + */ _getModifierKeyNames(os) { switch (os) { case 'win': @@ -255,6 +272,9 @@ export class HotkeyUtil { } } + /** + * @returns {void} + */ _updateModifierKeyNames() { const map = this._modifierKeyNames; map.clear(); @@ -263,6 +283,10 @@ export class HotkeyUtil { } } + /** + * @param {string} key + * @returns {string} + */ _convertCommandKeyToInputKey(key) { if (key.length === 1) { key = `Key${key}`; @@ -270,6 +294,10 @@ export class HotkeyUtil { return key; } + /** + * @param {string} modifier + * @returns {?import('input').ModifierKey} + */ _convertCommandModifierToInputModifier(modifier) { switch (modifier) { case 'Ctrl': return (this._os === 'mac' ? 'meta' : 'ctrl'); @@ -277,10 +305,14 @@ export class HotkeyUtil { case 'Shift': return 'shift'; case 'MacCtrl': return 'ctrl'; case 'Command': return 'meta'; - default: return modifier; + default: return null; } } + /** + * @param {string} key + * @returns {string} + */ _convertInputKeyToCommandKey(key) { if (key.length === 4 && key.startsWith('Key')) { key = key.substring(3); @@ -288,6 +320,10 @@ export class HotkeyUtil { return key; } + /** + * @param {import('input').Modifier} modifier + * @returns {string} + */ _convertInputModifierToCommandModifier(modifier) { switch (modifier) { case 'ctrl': return (this._os === 'mac' ? 'MacCtrl' : 'Ctrl'); diff --git a/ext/js/language/__mocks__/dictionary-importer-media-loader.js b/ext/js/language/__mocks__/dictionary-importer-media-loader.js index 96f0f6dd..ffda29b3 100644 --- a/ext/js/language/__mocks__/dictionary-importer-media-loader.js +++ b/ext/js/language/__mocks__/dictionary-importer-media-loader.js @@ -17,6 +17,7 @@ */ export class DictionaryImporterMediaLoader { + /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */ async getImageDetails(content) { // Placeholder values return {content, width: 100, height: 100}; diff --git a/ext/js/language/deinflector.js b/ext/js/language/deinflector.js index 3012c29a..b7a235d0 100644 --- a/ext/js/language/deinflector.js +++ b/ext/js/language/deinflector.js @@ -17,10 +17,18 @@ */ export class Deinflector { + /** + * @param {import('deinflector').ReasonsRaw} reasons + */ constructor(reasons) { + /** @type {import('deinflector').Reason[]} */ this.reasons = Deinflector.normalizeReasons(reasons); } + /** + * @param {string} source + * @returns {import('translation-internal').Deinflection[]} + */ deinflect(source) { const results = [this._createDeinflection(source, 0, [])]; for (let i = 0; i < results.length; ++i) { @@ -46,13 +54,25 @@ export class Deinflector { return results; } + /** + * @param {string} term + * @param {import('translation-internal').DeinflectionRuleFlags} rules + * @param {string[]} reasons + * @returns {import('translation-internal').Deinflection} + */ _createDeinflection(term, rules, reasons) { return {term, rules, reasons}; } + /** + * @param {import('deinflector').ReasonsRaw} reasons + * @returns {import('deinflector').Reason[]} + */ static normalizeReasons(reasons) { + /** @type {import('deinflector').Reason[]} */ const normalizedReasons = []; for (const [reason, reasonInfo] of Object.entries(reasons)) { + /** @type {import('deinflector').ReasonVariant[]} */ const variants = []; for (const {kanaIn, kanaOut, rulesIn, rulesOut} of reasonInfo) { variants.push([ @@ -67,6 +87,10 @@ export class Deinflector { return normalizedReasons; } + /** + * @param {string[]} rules + * @returns {import('translation-internal').DeinflectionRuleFlags} + */ static rulesToRuleFlags(rules) { const ruleTypes = this._ruleTypes; let value = 0; @@ -79,13 +103,14 @@ export class Deinflector { } } +/** @type {Map<string, import('translation-internal').DeinflectionRuleFlags>} */ // eslint-disable-next-line no-underscore-dangle Deinflector._ruleTypes = new Map([ - ['v1', 0b00000001], // Verb ichidan - ['v5', 0b00000010], // Verb godan - ['vs', 0b00000100], // Verb suru - ['vk', 0b00001000], // Verb kuru - ['vz', 0b00010000], // Verb zuru - ['adj-i', 0b00100000], // Adjective i - ['iru', 0b01000000] // Intermediate -iru endings for progressive or perfect tense + ['v1', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000001)], // Verb ichidan + ['v5', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000010)], // Verb godan + ['vs', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00000100)], // Verb suru + ['vk', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00001000)], // Verb kuru + ['vz', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00010000)], // Verb zuru + ['adj-i', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b00100000)], // Adjective i + ['iru', /** @type {import('translation-internal').DeinflectionRuleFlags} */ (0b01000000)] // Intermediate -iru endings for progressive or perfect tense ]); diff --git a/ext/js/language/dictionary-database.js b/ext/js/language/dictionary-database.js index da365da7..c47e1e90 100644 --- a/ext/js/language/dictionary-database.js +++ b/ext/js/language/dictionary-database.js @@ -21,29 +21,45 @@ import {Database} from '../data/database.js'; export class DictionaryDatabase { constructor() { + /** @type {Database<import('dictionary-database').ObjectStoreName>} */ this._db = new Database(); + /** @type {string} */ this._dbName = 'dict'; - this._schemas = new Map(); + /** @type {import('dictionary-database').CreateQuery<string>} */ this._createOnlyQuery1 = (item) => IDBKeyRange.only(item); + /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').DictionaryAndQueryRequest>} */ this._createOnlyQuery2 = (item) => IDBKeyRange.only(item.query); + /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').TermExactRequest>} */ this._createOnlyQuery3 = (item) => IDBKeyRange.only(item.term); + /** @type {import('dictionary-database').CreateQuery<import('dictionary-database').MediaRequest>} */ this._createOnlyQuery4 = (item) => IDBKeyRange.only(item.path); + /** @type {import('dictionary-database').CreateQuery<string>} */ this._createBoundQuery1 = (item) => IDBKeyRange.bound(item, `${item}\uffff`, false, false); + /** @type {import('dictionary-database').CreateQuery<string>} */ this._createBoundQuery2 = (item) => { item = stringReverse(item); return IDBKeyRange.bound(item, `${item}\uffff`, false, false); }; - this._createTermBind1 = this._createTerm.bind(this, 'term', 'exact'); - this._createTermBind2 = this._createTerm.bind(this, 'sequence', 'exact'); + /** @type {import('dictionary-database').CreateResult<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */ + this._createTermBind1 = this._createTermExact.bind(this); + /** @type {import('dictionary-database').CreateResult<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntryWithId, import('dictionary-database').TermEntry>} */ + this._createTermBind2 = this._createTermSequenceExact.bind(this); + /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseTermMeta, import('dictionary-database').TermMeta>} */ this._createTermMetaBind = this._createTermMeta.bind(this); + /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiEntry, import('dictionary-database').KanjiEntry>} */ this._createKanjiBind = this._createKanji.bind(this); + /** @type {import('dictionary-database').CreateResult<string, import('dictionary-database').DatabaseKanjiMeta, import('dictionary-database').KanjiMeta>} */ this._createKanjiMetaBind = this._createKanjiMeta.bind(this); + /** @type {import('dictionary-database').CreateResult<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent, import('dictionary-database').Media>} */ this._createMediaBind = this._createMedia.bind(this); } + /** */ async prepare() { await this._db.open( this._dbName, 60, - [ - { + /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>[]} */ + ([ + /** @type {import('database').StructureDefinition<import('dictionary-database').ObjectStoreName>} */ + ({ version: 20, stores: { terms: { @@ -63,7 +79,7 @@ export class DictionaryDatabase { indices: ['title', 'version'] } } - }, + }), { version: 30, stores: { @@ -108,18 +124,25 @@ export class DictionaryDatabase { } } } - ] + ]) ); } + /** */ async close() { this._db.close(); } + /** + * @returns {boolean} + */ isPrepared() { return this._db.isOpen(); } + /** + * @returns {Promise<boolean>} + */ async purge() { if (this._db.isOpening()) { throw new Error('Cannot purge database while opening'); @@ -138,14 +161,13 @@ export class DictionaryDatabase { return result; } + /** + * @param {string} dictionaryName + * @param {number} progressRate + * @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress + */ async deleteDictionary(dictionaryName, progressRate, onProgress) { - if (typeof progressRate !== 'number') { - progressRate = 1; - } - if (typeof onProgress !== 'function') { - onProgress = () => {}; - } - + /** @type {[objectStoreName: import('dictionary-database').ObjectStoreName, key: string][][]} */ const targetGroups = [ [ ['kanji', 'dictionary'], @@ -165,6 +187,7 @@ export class DictionaryDatabase { storeCount += targets.length; } + /** @type {import('dictionary-database').DeleteDictionaryProgressData} */ const progressData = { count: 0, processed: 0, @@ -172,6 +195,10 @@ export class DictionaryDatabase { storesProcesed: 0 }; + /** + * @param {IDBValidKey[]} keys + * @returns {IDBValidKey[]} + */ const filterKeys = (keys) => { ++progressData.storesProcesed; progressData.count += keys.length; @@ -197,8 +224,15 @@ export class DictionaryDatabase { } } + /** + * @param {string[]} termList + * @param {import('dictionary-database').DictionarySet} dictionaries + * @param {import('dictionary-database').MatchType} matchType + * @returns {Promise<import('dictionary-database').TermEntry[]>} + */ findTermsBulk(termList, dictionaries, matchType) { const visited = new Set(); + /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermEntryWithId>} */ const predicate = (row) => { if (!dictionaries.has(row.dictionary)) { return false; } const {id} = row; @@ -224,54 +258,106 @@ export class DictionaryDatabase { return this._findMultiBulk('terms', indexNames, termList, createQuery, predicate, createResult); } + /** + * @param {import('dictionary-database').TermExactRequest[]} termList + * @param {import('dictionary-database').DictionarySet} dictionaries + * @returns {Promise<import('dictionary-database').TermEntry[]>} + */ findTermsExactBulk(termList, dictionaries) { + /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').TermExactRequest, import('dictionary-database').DatabaseTermEntry>} */ const predicate = (row, item) => (row.reading === item.reading && dictionaries.has(row.dictionary)); return this._findMultiBulk('terms', ['expression'], termList, this._createOnlyQuery3, predicate, this._createTermBind1); } + /** + * @param {import('dictionary-database').DictionaryAndQueryRequest[]} items + * @returns {Promise<import('dictionary-database').TermEntry[]>} + */ findTermsBySequenceBulk(items) { + /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').DatabaseTermEntry>} */ const predicate = (row, item) => (row.dictionary === item.dictionary); return this._findMultiBulk('terms', ['sequence'], items, this._createOnlyQuery2, predicate, this._createTermBind2); } + /** + * @param {string[]} termList + * @param {import('dictionary-database').DictionarySet} dictionaries + * @returns {Promise<import('dictionary-database').TermMeta[]>} + */ findTermMetaBulk(termList, dictionaries) { + /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseTermMeta>} */ const predicate = (row) => dictionaries.has(row.dictionary); return this._findMultiBulk('termMeta', ['expression'], termList, this._createOnlyQuery1, predicate, this._createTermMetaBind); } + /** + * @param {string[]} kanjiList + * @param {import('dictionary-database').DictionarySet} dictionaries + * @returns {Promise<import('dictionary-database').KanjiEntry[]>} + */ findKanjiBulk(kanjiList, dictionaries) { + /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiEntry>} */ const predicate = (row) => dictionaries.has(row.dictionary); return this._findMultiBulk('kanji', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiBind); } + /** + * @param {string[]} kanjiList + * @param {import('dictionary-database').DictionarySet} dictionaries + * @returns {Promise<import('dictionary-database').KanjiMeta[]>} + */ findKanjiMetaBulk(kanjiList, dictionaries) { + /** @type {import('dictionary-database').FindPredicate<string, import('dictionary-database').DatabaseKanjiMeta>} */ const predicate = (row) => dictionaries.has(row.dictionary); return this._findMultiBulk('kanjiMeta', ['character'], kanjiList, this._createOnlyQuery1, predicate, this._createKanjiMetaBind); } + /** + * @param {import('dictionary-database').DictionaryAndQueryRequest[]} items + * @returns {Promise<(import('dictionary-database').Tag|undefined)[]>} + */ findTagMetaBulk(items) { + /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').DictionaryAndQueryRequest, import('dictionary-database').Tag>} */ const predicate = (row, item) => (row.dictionary === item.dictionary); return this._findFirstBulk('tagMeta', 'name', items, this._createOnlyQuery2, predicate); } - findTagForTitle(name, title) { + /** + * @param {string} name + * @param {string} dictionary + * @returns {Promise<?import('dictionary-database').Tag>} + */ + findTagForTitle(name, dictionary) { const query = IDBKeyRange.only(name); - return this._db.find('tagMeta', 'name', query, (row) => (row.dictionary === title), null, null); + return this._db.find('tagMeta', 'name', query, (row) => (/** @type {import('dictionary-database').Tag} */ (row).dictionary === dictionary), null, null); } + /** + * @param {import('dictionary-database').MediaRequest[]} items + * @returns {Promise<import('dictionary-database').Media[]>} + */ getMedia(items) { + /** @type {import('dictionary-database').FindPredicate<import('dictionary-database').MediaRequest, import('dictionary-database').MediaDataArrayBufferContent>} */ const predicate = (row, item) => (row.dictionary === item.dictionary); return this._findMultiBulk('media', ['path'], items, this._createOnlyQuery4, predicate, this._createMediaBind); } + /** + * @returns {Promise<import('dictionary-importer').Summary[]>} + */ getDictionaryInfo() { return new Promise((resolve, reject) => { const transaction = this._db.transaction(['dictionaries'], 'readonly'); const objectStore = transaction.objectStore('dictionaries'); - this._db.getAll(objectStore, null, resolve, reject); + this._db.getAll(objectStore, null, resolve, reject, null); }); } + /** + * @param {string[]} dictionaryNames + * @param {boolean} getTotal + * @returns {Promise<import('dictionary-database').DictionaryCounts>} + */ getDictionaryCounts(dictionaryNames, getTotal) { return new Promise((resolve, reject) => { const targets = [ @@ -290,10 +376,11 @@ export class DictionaryDatabase { return {objectStore, index}; }); + /** @type {import('database').CountTarget[]} */ const countTargets = []; if (getTotal) { for (const {objectStore} of databaseTargets) { - countTargets.push([objectStore, null]); + countTargets.push([objectStore, void 0]); } } for (const dictionaryName of dictionaryNames) { @@ -303,18 +390,23 @@ export class DictionaryDatabase { } } + /** + * @param {number[]} results + */ const onCountComplete = (results) => { const resultCount = results.length; const targetCount = targets.length; + /** @type {import('dictionary-database').DictionaryCountGroup[]} */ const counts = []; for (let i = 0; i < resultCount; i += targetCount) { + /** @type {import('dictionary-database').DictionaryCountGroup} */ const countGroup = {}; for (let j = 0; j < targetCount; ++j) { countGroup[targets[j][0]] = results[i + j]; } counts.push(countGroup); } - const total = getTotal ? counts.shift() : null; + const total = getTotal ? /** @type {import('dictionary-database').DictionaryCountGroup} */ (counts.shift()) : null; resolve({total, counts}); }; @@ -322,22 +414,47 @@ export class DictionaryDatabase { }); } + /** + * @param {string} title + * @returns {Promise<boolean>} + */ async dictionaryExists(title) { const query = IDBKeyRange.only(title); const result = await this._db.find('dictionaries', 'title', query, null, null, void 0); return typeof result !== 'undefined'; } + /** + * @template {import('dictionary-database').ObjectStoreName} T + * @param {T} objectStoreName + * @param {import('dictionary-database').ObjectStoreData<T>[]} items + * @param {number} start + * @param {number} count + * @returns {Promise<void>} + */ bulkAdd(objectStoreName, items, start, count) { return this._db.bulkAdd(objectStoreName, items, start, count); } // Private + /** + * @template [TRow=unknown] + * @template [TItem=unknown] + * @template [TResult=unknown] + * @param {import('dictionary-database').ObjectStoreName} objectStoreName + * @param {string[]} indexNames + * @param {TItem[]} items + * @param {import('dictionary-database').CreateQuery<TItem>} createQuery + * @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate + * @param {import('dictionary-database').CreateResult<TItem, TRow, TResult>} createResult + * @returns {Promise<TResult[]>} + */ _findMultiBulk(objectStoreName, indexNames, items, createQuery, predicate, createResult) { return new Promise((resolve, reject) => { const itemCount = items.length; const indexCount = indexNames.length; + /** @type {TResult[]} */ const results = []; if (itemCount === 0 || indexCount === 0) { resolve(results); @@ -352,6 +469,10 @@ export class DictionaryDatabase { } let completeCount = 0; const requiredCompleteCount = itemCount * indexCount; + /** + * @param {TRow[]} rows + * @param {import('dictionary-database').FindMultiBulkData<TItem>} data + */ const onGetAll = (rows, data) => { for (const row of rows) { if (predicate(row, data.item)) { @@ -366,15 +487,28 @@ export class DictionaryDatabase { const item = items[i]; const query = createQuery(item); for (let j = 0; j < indexCount; ++j) { - this._db.getAll(indexList[j], query, onGetAll, reject, {item, itemIndex: i, indexIndex: j}); + /** @type {import('dictionary-database').FindMultiBulkData<TItem>} */ + const data = {item, itemIndex: i, indexIndex: j}; + this._db.getAll(indexList[j], query, onGetAll, reject, data); } } }); } + /** + * @template [TRow=unknown] + * @template [TItem=unknown] + * @param {import('dictionary-database').ObjectStoreName} objectStoreName + * @param {string} indexName + * @param {TItem[]} items + * @param {import('dictionary-database').CreateQuery<TItem>} createQuery + * @param {import('dictionary-database').FindPredicate<TItem, TRow>} predicate + * @returns {Promise<(TRow|undefined)[]>} + */ _findFirstBulk(objectStoreName, indexName, items, createQuery, predicate) { return new Promise((resolve, reject) => { const itemCount = items.length; + /** @type {(TRow|undefined)[]} */ const results = new Array(itemCount); if (itemCount === 0) { resolve(results); @@ -385,6 +519,10 @@ export class DictionaryDatabase { const objectStore = transaction.objectStore(objectStoreName); const index = objectStore.index(indexName); let completeCount = 0; + /** + * @param {TRow|undefined} row + * @param {number} itemIndex + */ const onFind = (row, itemIndex) => { results[itemIndex] = row; if (++completeCount >= itemCount) { @@ -399,16 +537,47 @@ export class DictionaryDatabase { }); } + /** + * @param {import('dictionary-database').MatchType} matchType + * @param {import('dictionary-database').DatabaseTermEntryWithId} row + * @param {import('dictionary-database').FindMultiBulkData<string>} data + * @returns {import('dictionary-database').TermEntry} + */ _createTermGeneric(matchType, row, data) { const matchSourceIsTerm = (data.indexIndex === 0); const matchSource = (matchSourceIsTerm ? 'term' : 'reading'); if ((matchSourceIsTerm ? row.expression : row.reading) === data.item) { matchType = 'exact'; } - return this._createTerm(matchSource, matchType, row, data); + return this._createTerm(matchSource, matchType, row, data.itemIndex); + } + + /** + * @param {import('dictionary-database').DatabaseTermEntryWithId} row + * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').TermExactRequest>} data + * @returns {import('dictionary-database').TermEntry} + */ + _createTermExact(row, data) { + return this._createTerm('term', 'exact', row, data.itemIndex); } - _createTerm(matchSource, matchType, row, {itemIndex: index}) { + /** + * @param {import('dictionary-database').DatabaseTermEntryWithId} row + * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').DictionaryAndQueryRequest>} data + * @returns {import('dictionary-database').TermEntry} + */ + _createTermSequenceExact(row, data) { + return this._createTerm('sequence', 'exact', row, data.itemIndex); + } + + /** + * @param {import('dictionary-database').MatchSource} matchSource + * @param {import('dictionary-database').MatchType} matchType + * @param {import('dictionary-database').DatabaseTermEntryWithId} row + * @param {number} index + * @returns {import('dictionary-database').TermEntry} + */ + _createTerm(matchSource, matchType, row, index) { const {sequence} = row; return { index, @@ -427,7 +596,13 @@ export class DictionaryDatabase { }; } + /** + * @param {import('dictionary-database').DatabaseKanjiEntry} row + * @param {import('dictionary-database').FindMultiBulkData<string>} data + * @returns {import('dictionary-database').KanjiEntry} + */ _createKanji(row, {itemIndex: index}) { + const {stats} = row; return { index, character: row.character, @@ -435,23 +610,51 @@ export class DictionaryDatabase { kunyomi: this._splitField(row.kunyomi), tags: this._splitField(row.tags), definitions: row.meanings, - stats: row.stats, + stats: typeof stats === 'object' && stats !== null ? stats : {}, dictionary: row.dictionary }; } + /** + * @param {import('dictionary-database').DatabaseTermMeta} row + * @param {import('dictionary-database').FindMultiBulkData<string>} data + * @returns {import('dictionary-database').TermMeta} + * @throws {Error} + */ _createTermMeta({expression: term, mode, data, dictionary}, {itemIndex: index}) { - return {term, mode, data, dictionary, index}; + switch (mode) { + case 'freq': + return {index, term, mode, data, dictionary}; + case 'pitch': + return {index, term, mode, data, dictionary}; + default: + throw new Error(`Unknown mode: ${mode}`); + } } + /** + * @param {import('dictionary-database').DatabaseKanjiMeta} row + * @param {import('dictionary-database').FindMultiBulkData<string>} data + * @returns {import('dictionary-database').KanjiMeta} + */ _createKanjiMeta({character, mode, data, dictionary}, {itemIndex: index}) { - return {character, mode, data, dictionary, index}; + return {index, character, mode, data, dictionary}; } + /** + * @param {import('dictionary-database').MediaDataArrayBufferContent} row + * @param {import('dictionary-database').FindMultiBulkData<import('dictionary-database').MediaRequest>} data + * @returns {import('dictionary-database').Media} + */ _createMedia(row, {itemIndex: index}) { - return Object.assign({}, row, {index}); + const {dictionary, path, mediaType, width, height, content} = row; + return {index, dictionary, path, mediaType, width, height, content}; } + /** + * @param {unknown} field + * @returns {string[]} + */ _splitField(field) { return typeof field === 'string' && field.length > 0 ? field.split(' ') : []; } diff --git a/ext/js/language/dictionary-importer-media-loader.js b/ext/js/language/dictionary-importer-media-loader.js index 7d4f798c..a5857dce 100644 --- a/ext/js/language/dictionary-importer-media-loader.js +++ b/ext/js/language/dictionary-importer-media-loader.js @@ -22,15 +22,7 @@ import {EventListenerCollection} from '../core.js'; * Class used for loading and validating media during the dictionary import process. */ export class DictionaryImporterMediaLoader { - /** - * Attempts to load an image using an ArrayBuffer and a media type to return details about it. - * @param {ArrayBuffer} content The binary content for the image, encoded as an ArrayBuffer. - * @param {string} mediaType The media type for the image content. - * @param {Transferable[]} [transfer] An optional array of data that should be transferred in `postMessage` calls. - * When the resulting promise resolves, this array will contain the `content` object. - * @returns {Promise<{content: ArrayBuffer, width: number, height: number}>} Details about the requested image content. - * @throws {Error} An error can be thrown if the image fails to load. - */ + /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */ getImageDetails(content, mediaType, transfer) { return new Promise((resolve, reject) => { const image = new Image(); diff --git a/ext/js/language/dictionary-importer.js b/ext/js/language/dictionary-importer.js index 791d1a77..08fcf86b 100644 --- a/ext/js/language/dictionary-importer.js +++ b/ext/js/language/dictionary-importer.js @@ -16,17 +16,44 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import * as ajvSchemas from '../../lib/validate-schemas.js'; -import {BlobWriter, TextWriter, Uint8ArrayReader, ZipReader, configure} from '../../lib/zip.js'; +import * as ajvSchemas0 from '../../lib/validate-schemas.js'; +import { + BlobWriter as BlobWriter0, + TextWriter as TextWriter0, + Uint8ArrayReader as Uint8ArrayReader0, + ZipReader as ZipReader0, + configure +} from '../../lib/zip.js'; import {stringReverse} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {MediaUtil} from '../media/media-util.js'; + +const ajvSchemas = /** @type {import('dictionary-importer').CompiledSchemaValidators} */ (/** @type {unknown} */ (ajvSchemas0)); +const BlobWriter = /** @type {typeof import('@zip.js/zip.js').BlobWriter} */ (/** @type {unknown} */ (BlobWriter0)); +const TextWriter = /** @type {typeof import('@zip.js/zip.js').TextWriter} */ (/** @type {unknown} */ (TextWriter0)); +const Uint8ArrayReader = /** @type {typeof import('@zip.js/zip.js').Uint8ArrayReader} */ (/** @type {unknown} */ (Uint8ArrayReader0)); +const ZipReader = /** @type {typeof import('@zip.js/zip.js').ZipReader} */ (/** @type {unknown} */ (ZipReader0)); + export class DictionaryImporter { + /** + * @param {import('dictionary-importer-media-loader').GenericMediaLoader} mediaLoader + * @param {import('dictionary-importer').OnProgressCallback} [onProgress] + */ constructor(mediaLoader, onProgress) { + /** @type {import('dictionary-importer-media-loader').GenericMediaLoader} */ this._mediaLoader = mediaLoader; + /** @type {import('dictionary-importer').OnProgressCallback} */ this._onProgress = typeof onProgress === 'function' ? onProgress : () => {}; - this._progressData = null; + /** @type {import('dictionary-importer').ProgressData} */ + this._progressData = this._createProgressData(); } + /** + * @param {import('./dictionary-database.js').DictionaryDatabase} dictionaryDatabase + * @param {ArrayBuffer} archiveContent + * @param {import('dictionary-importer').ImportDetails} details + * @returns {Promise<import('dictionary-importer').ImportResult>} + */ async importDictionary(dictionaryDatabase, archiveContent, details) { if (!dictionaryDatabase) { throw new Error('Invalid database'); @@ -48,30 +75,30 @@ export class DictionaryImporter { const zipFileReader = new Uint8ArrayReader(new Uint8Array(archiveContent)); const zipReader = new ZipReader(zipFileReader); const zipEntries = await zipReader.getEntries(); - const zipEntriesObject = {}; + /** @type {import('dictionary-importer').ArchiveFileMap} */ + const fileMap = new Map(); for (const entry of zipEntries) { - zipEntriesObject[entry.filename] = entry; + fileMap.set(entry.filename, entry); } // Read and validate index const indexFileName = 'index.json'; - const indexFile = zipEntriesObject[indexFileName]; - if (!indexFile) { + const indexFile = fileMap.get(indexFileName); + if (typeof indexFile === 'undefined') { throw new Error('No dictionary index found in archive'); } + const indexFile2 = /** @type {import('@zip.js/zip.js').Entry} */ (indexFile); - const indexContent = await indexFile.getData( - new TextWriter() - ); - const index = JSON.parse(indexContent); + const indexContent = await this._getData(indexFile2, new TextWriter()); + const index = /** @type {import('dictionary-data').Index} */ (JSON.parse(indexContent)); if (!ajvSchemas.dictionaryIndex(index)) { throw this._formatAjvSchemaError(ajvSchemas.dictionaryIndex, indexFileName); } const dictionaryTitle = index.title; - const version = index.format || index.version; + const version = typeof index.format === 'number' ? index.format : index.version; - if (!dictionaryTitle || !index.revision) { + if (typeof version !== 'number' || !dictionaryTitle || !index.revision) { throw new Error('Unrecognized dictionary format'); } @@ -80,31 +107,32 @@ export class DictionaryImporter { throw new Error('Dictionary is already imported'); } - // Data format converters - const convertTermBankEntry = (version === 1 ? this._convertTermBankEntryV1.bind(this) : this._convertTermBankEntryV3.bind(this)); - const convertTermMetaBankEntry = this._convertTermMetaBankEntry.bind(this); - const convertKanjiBankEntry = (version === 1 ? this._convertKanjiBankEntryV1.bind(this) : this._convertKanjiBankEntryV3.bind(this)); - const convertKanjiMetaBankEntry = this._convertKanjiMetaBankEntry.bind(this); - const convertTagBankEntry = this._convertTagBankEntry.bind(this); - // Load schemas this._progressNextStep(0); const dataBankSchemas = this._getDataBankSchemas(version); // Files - const termFiles = this._getArchiveFiles(zipEntriesObject, 'term_bank_?.json'); - const termMetaFiles = this._getArchiveFiles(zipEntriesObject, 'term_meta_bank_?.json'); - const kanjiFiles = this._getArchiveFiles(zipEntriesObject, 'kanji_bank_?.json'); - const kanjiMetaFiles = this._getArchiveFiles(zipEntriesObject, 'kanji_meta_bank_?.json'); - const tagFiles = this._getArchiveFiles(zipEntriesObject, 'tag_bank_?.json'); + const termFiles = this._getArchiveFiles(fileMap, 'term_bank_?.json'); + const termMetaFiles = this._getArchiveFiles(fileMap, 'term_meta_bank_?.json'); + const kanjiFiles = this._getArchiveFiles(fileMap, 'kanji_bank_?.json'); + const kanjiMetaFiles = this._getArchiveFiles(fileMap, 'kanji_meta_bank_?.json'); + const tagFiles = this._getArchiveFiles(fileMap, 'tag_bank_?.json'); // Load data this._progressNextStep(termFiles.length + termMetaFiles.length + kanjiFiles.length + kanjiMetaFiles.length + tagFiles.length); - const termList = await this._readFileSequence(termFiles, convertTermBankEntry, dataBankSchemas[0], dictionaryTitle); - const termMetaList = await this._readFileSequence(termMetaFiles, convertTermMetaBankEntry, dataBankSchemas[1], dictionaryTitle); - const kanjiList = await this._readFileSequence(kanjiFiles, convertKanjiBankEntry, dataBankSchemas[2], dictionaryTitle); - const kanjiMetaList = await this._readFileSequence(kanjiMetaFiles, convertKanjiMetaBankEntry, dataBankSchemas[3], dictionaryTitle); - const tagList = await this._readFileSequence(tagFiles, convertTagBankEntry, dataBankSchemas[4], dictionaryTitle); + const termList = await ( + version === 1 ? + this._readFileSequence(termFiles, this._convertTermBankEntryV1.bind(this), dataBankSchemas[0], dictionaryTitle) : + this._readFileSequence(termFiles, this._convertTermBankEntryV3.bind(this), dataBankSchemas[0], dictionaryTitle) + ); + const termMetaList = await this._readFileSequence(termMetaFiles, this._convertTermMetaBankEntry.bind(this), dataBankSchemas[1], dictionaryTitle); + const kanjiList = await ( + version === 1 ? + this._readFileSequence(kanjiFiles, this._convertKanjiBankEntryV1.bind(this), dataBankSchemas[2], dictionaryTitle) : + this._readFileSequence(kanjiFiles, this._convertKanjiBankEntryV3.bind(this), dataBankSchemas[2], dictionaryTitle) + ); + const kanjiMetaList = await this._readFileSequence(kanjiMetaFiles, this._convertKanjiMetaBankEntry.bind(this), dataBankSchemas[3], dictionaryTitle); + const tagList = await this._readFileSequence(tagFiles, this._convertTagBankEntry.bind(this), dataBankSchemas[4], dictionaryTitle); this._addOldIndexTags(index, tagList, dictionaryTitle); // Prefix wildcard support @@ -119,6 +147,7 @@ export class DictionaryImporter { // Extended data support this._progressNextStep(termList.length); const formatProgressInterval = 1000; + /** @type {import('dictionary-importer').ImportRequirement[]} */ const requirements = []; for (let i = 0, ii = termList.length; i < ii; ++i) { const entry = termList[i]; @@ -137,11 +166,12 @@ export class DictionaryImporter { // Async requirements this._progressNextStep(requirements.length); - const {media} = await this._resolveAsyncRequirements(requirements, zipEntriesObject); + const {media} = await this._resolveAsyncRequirements(requirements, fileMap); // Add dictionary descriptor this._progressNextStep(termList.length + termMetaList.length + kanjiList.length + kanjiMetaList.length + tagList.length + media.length); + /** @type {import('dictionary-importer').SummaryCounts} */ const counts = { terms: {total: termList.length}, termMeta: this._getMetaCounts(termMetaList), @@ -154,9 +184,15 @@ export class DictionaryImporter { dictionaryDatabase.bulkAdd('dictionaries', [summary], 0, 1); // Add data + /** @type {Error[]} */ const errors = []; const maxTransactionLength = 1000; + /** + * @template {import('dictionary-database').ObjectStoreName} T + * @param {T} objectStoreName + * @param {import('dictionary-database').ObjectStoreData<T>[]} entries + */ const bulkAdd = async (objectStoreName, entries) => { const ii = entries.length; for (let i = 0; i < ii; i += maxTransactionLength) { @@ -165,7 +201,7 @@ export class DictionaryImporter { try { await dictionaryDatabase.bulkAdd(objectStoreName, entries, i, count); } catch (e) { - errors.push(e); + errors.push(e instanceof Error ? e : new Error(`${e}`)); } this._progressData.index += count; @@ -185,16 +221,27 @@ export class DictionaryImporter { return {result: summary, errors}; } - _progressReset() { - this._progressData = { + /** + * @returns {import('dictionary-importer').ProgressData} + */ + _createProgressData() { + return { stepIndex: 0, stepCount: 6, index: 0, count: 0 }; + } + + /** */ + _progressReset() { + this._progressData = this._createProgressData(); this._progress(); } + /** + * @param {number} count + */ _progressNextStep(count) { ++this._progressData.stepIndex; this._progressData.index = 0; @@ -202,17 +249,31 @@ export class DictionaryImporter { this._progress(); } + /** */ _progress() { this._onProgress(this._progressData); } + /** + * @param {string} dictionaryTitle + * @param {number} version + * @param {import('dictionary-data').Index} index + * @param {{prefixWildcardsSupported: boolean, counts: import('dictionary-importer').SummaryCounts}} details + * @returns {import('dictionary-importer').Summary} + */ _createSummary(dictionaryTitle, version, index, details) { + const indexSequenced = index.sequenced; + const {prefixWildcardsSupported, counts} = details; + + /** @type {import('dictionary-importer').Summary} */ const summary = { title: dictionaryTitle, revision: index.revision, - sequenced: index.sequenced, + sequenced: typeof indexSequenced === 'boolean' && indexSequenced, version, - importDate: Date.now() + importDate: Date.now(), + prefixWildcardsSupported, + counts }; const {author, url, description, attribution, frequencyMode} = index; @@ -222,18 +283,25 @@ export class DictionaryImporter { if (typeof attribution === 'string') { summary.attribution = attribution; } if (typeof frequencyMode === 'string') { summary.frequencyMode = frequencyMode; } - Object.assign(summary, details); - return summary; } + /** + * @param {import('ajv').ValidateFunction} schema + * @param {string} fileName + * @returns {ExtensionError} + */ _formatAjvSchemaError(schema, fileName) { - const e2 = new Error(`Dictionary has invalid data in '${fileName}'`); + const e2 = new ExtensionError(`Dictionary has invalid data in '${fileName}'`); e2.data = schema.errors; return e2; } + /** + * @param {number} version + * @returns {import('dictionary-importer').CompiledSchemaNameArray} + */ _getDataBankSchemas(version) { const termBank = ( version === 1 ? @@ -252,6 +320,13 @@ export class DictionaryImporter { return [termBank, termMetaBank, kanjiBank, kanjiMetaBank, tagBank]; } + /** + * @param {import('dictionary-data').TermGlossaryText|import('dictionary-data').TermGlossaryImage|import('dictionary-data').TermGlossaryStructuredContent} data + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @returns {import('dictionary-data').TermGlossary} + * @throws {Error} + */ _formatDictionaryTermGlossaryObject(data, entry, requirements) { switch (data.type) { case 'text': @@ -261,16 +336,32 @@ export class DictionaryImporter { case 'structured-content': return this._formatStructuredContent(data, entry, requirements); default: - throw new Error(`Unhandled data type: ${data.type}`); + throw new Error(`Unhandled data type: ${/** @type {import('core').SerializableObject} */ (data).type}`); } } + /** + * @param {import('dictionary-data').TermGlossaryImage} data + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @returns {import('dictionary-data').TermGlossaryImage} + */ _formatDictionaryTermGlossaryImage(data, entry, requirements) { - const target = {}; - requirements.push({type: 'image', target, args: [data, entry]}); + /** @type {import('dictionary-data').TermGlossaryImage} */ + const target = { + type: 'image', + path: '' // Will be populated during requirement resolution + }; + requirements.push({type: 'image', target, source: data, entry}); return target; } + /** + * @param {import('dictionary-data').TermGlossaryStructuredContent} data + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @returns {import('dictionary-data').TermGlossaryStructuredContent} + */ _formatStructuredContent(data, entry, requirements) { const content = this._prepareStructuredContent(data.content, entry, requirements); return { @@ -279,6 +370,12 @@ export class DictionaryImporter { }; } + /** + * @param {import('structured-content').Content} content + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @returns {import('structured-content').Content} + */ _prepareStructuredContent(content, entry, requirements) { if (typeof content === 'string' || !(typeof content === 'object' && content !== null)) { return content; @@ -301,15 +398,32 @@ export class DictionaryImporter { return content; } + /** + * @param {import('structured-content').ImageElement} content + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @returns {import('structured-content').ImageElement} + */ _prepareStructuredContentImage(content, entry, requirements) { - const target = {}; - requirements.push({type: 'structured-content-image', target, args: [content, entry]}); + /** @type {import('structured-content').ImageElement} */ + const target = { + tag: 'img', + path: '' // Will be populated during requirement resolution + }; + requirements.push({type: 'structured-content-image', target, source: content, entry}); return target; } - async _resolveAsyncRequirements(requirements, zipEntriesObject) { + /** + * @param {import('dictionary-importer').ImportRequirement[]} requirements + * @param {import('dictionary-importer').ArchiveFileMap} fileMap + * @returns {Promise<{media: import('dictionary-database').MediaDataArrayBufferContent[]}>} + */ + async _resolveAsyncRequirements(requirements, fileMap) { + /** @type {Map<string, import('dictionary-database').MediaDataArrayBufferContent>} */ const media = new Map(); - const context = {zipEntriesObject, media}; + /** @type {import('dictionary-importer').ImportRequirementContext} */ + const context = {fileMap, media}; for (const requirement of requirements) { await this._resolveAsyncRequirement(context, requirement); @@ -320,37 +434,65 @@ export class DictionaryImporter { }; } + /** + * @param {import('dictionary-importer').ImportRequirementContext} context + * @param {import('dictionary-importer').ImportRequirement} requirement + */ async _resolveAsyncRequirement(context, requirement) { - const {type, target, args} = requirement; - let result; - switch (type) { + switch (requirement.type) { case 'image': - result = await this._resolveDictionaryTermGlossaryImage(context, ...args); + await this._resolveDictionaryTermGlossaryImage( + context, + requirement.target, + requirement.source, + requirement.entry + ); break; case 'structured-content-image': - result = await this._resolveStructuredContentImage(context, ...args); + await this._resolveStructuredContentImage( + context, + requirement.target, + requirement.source, + requirement.entry + ); break; default: return; } - Object.assign(target, result); ++this._progressData.index; this._progress(); } - async _resolveDictionaryTermGlossaryImage(context, data, entry) { - return await this._createImageData(context, data, entry, {type: 'image'}); - } - - async _resolveStructuredContentImage(context, content, entry) { - const {verticalAlign, sizeUnits} = content; - const result = await this._createImageData(context, content, entry, {tag: 'img'}); - if (typeof verticalAlign === 'string') { result.verticalAlign = verticalAlign; } - if (typeof sizeUnits === 'string') { result.sizeUnits = sizeUnits; } - return result; - } - - async _createImageData(context, data, entry, attributes) { + /** + * @param {import('dictionary-importer').ImportRequirementContext} context + * @param {import('dictionary-data').TermGlossaryImage} target + * @param {import('dictionary-data').TermGlossaryImage} source + * @param {import('dictionary-database').DatabaseTermEntry} entry + */ + async _resolveDictionaryTermGlossaryImage(context, target, source, entry) { + await this._createImageData(context, target, source, entry); + } + + /** + * @param {import('dictionary-importer').ImportRequirementContext} context + * @param {import('structured-content').ImageElement} target + * @param {import('structured-content').ImageElement} source + * @param {import('dictionary-database').DatabaseTermEntry} entry + */ + async _resolveStructuredContentImage(context, target, source, entry) { + const {verticalAlign, sizeUnits} = source; + await this._createImageData(context, target, source, entry); + if (typeof verticalAlign === 'string') { target.verticalAlign = verticalAlign; } + if (typeof sizeUnits === 'string') { target.sizeUnits = sizeUnits; } + } + + /** + * @param {import('dictionary-importer').ImportRequirementContext} context + * @param {import('structured-content').ImageElementBase} target + * @param {import('structured-content').ImageElementBase} source + * @param {import('dictionary-database').DatabaseTermEntry} entry + */ + async _createImageData(context, target, source, entry) { const { path, width: preferredWidth, @@ -363,26 +505,37 @@ export class DictionaryImporter { background, collapsed, collapsible - } = data; + } = source; const {width, height} = await this._getImageMedia(context, path, entry); - const newData = Object.assign({}, attributes, {path, width, height}); - if (typeof preferredWidth === 'number') { newData.preferredWidth = preferredWidth; } - if (typeof preferredHeight === 'number') { newData.preferredHeight = preferredHeight; } - if (typeof title === 'string') { newData.title = title; } - if (typeof description === 'string') { newData.description = description; } - if (typeof pixelated === 'boolean') { newData.pixelated = pixelated; } - if (typeof imageRendering === 'string') { newData.imageRendering = imageRendering; } - if (typeof appearance === 'string') { newData.appearance = appearance; } - if (typeof background === 'boolean') { newData.background = background; } - if (typeof collapsed === 'boolean') { newData.collapsed = collapsed; } - if (typeof collapsible === 'boolean') { newData.collapsible = collapsible; } - return newData; - } - + target.path = path; + target.width = width; + target.height = height; + if (typeof preferredWidth === 'number') { target.preferredWidth = preferredWidth; } + if (typeof preferredHeight === 'number') { target.preferredHeight = preferredHeight; } + if (typeof title === 'string') { target.title = title; } + if (typeof description === 'string') { target.description = description; } + if (typeof pixelated === 'boolean') { target.pixelated = pixelated; } + if (typeof imageRendering === 'string') { target.imageRendering = imageRendering; } + if (typeof appearance === 'string') { target.appearance = appearance; } + if (typeof background === 'boolean') { target.background = background; } + if (typeof collapsed === 'boolean') { target.collapsed = collapsed; } + if (typeof collapsible === 'boolean') { target.collapsible = collapsible; } + } + + /** + * @param {import('dictionary-importer').ImportRequirementContext} context + * @param {string} path + * @param {import('dictionary-database').DatabaseTermEntry} entry + * @returns {Promise<import('dictionary-database').MediaDataArrayBufferContent>} + */ async _getImageMedia(context, path, entry) { const {media} = context; const {dictionary} = entry; + /** + * @param {string} message + * @returns {Error} + */ const createError = (message) => { const {expression, reading} = entry; const readingSource = reading.length > 0 ? ` (${reading})`: ''; @@ -399,15 +552,13 @@ export class DictionaryImporter { } // Find file in archive - const file = context.zipEntriesObject[path]; - if (file === null) { + const file = context.fileMap.get(path); + if (typeof file === 'undefined') { throw createError('Could not find image'); } // Load file content - let content = await (await file.getData( - new BlobWriter() - )).arrayBuffer(); + let content = await (await this._getData(file, new BlobWriter())).arrayBuffer(); const mediaType = MediaUtil.getImageMediaTypeFromFileName(path); if (mediaType === null) { @@ -437,6 +588,10 @@ export class DictionaryImporter { return mediaData; } + /** + * @param {string} url + * @returns {Promise<unknown>} + */ async _fetchJsonAsset(url) { const response = await fetch(url, { method: 'GET', @@ -452,6 +607,11 @@ export class DictionaryImporter { return await response.json(); } + /** + * @param {import('dictionary-data').TermV1} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseTermEntry} + */ _convertTermBankEntryV1(entry, dictionary) { let [expression, reading, definitionTags, rules, score, ...glossary] = entry; expression = this._normalizeTermOrReading(expression); @@ -459,6 +619,11 @@ export class DictionaryImporter { return {expression, reading, definitionTags, rules, score, glossary, dictionary}; } + /** + * @param {import('dictionary-data').TermV3} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseTermEntry} + */ _convertTermBankEntryV3(entry, dictionary) { let [expression, reading, definitionTags, rules, score, glossary, sequence, termTags] = entry; expression = this._normalizeTermOrReading(expression); @@ -466,88 +631,140 @@ export class DictionaryImporter { return {expression, reading, definitionTags, rules, score, glossary, sequence, termTags, dictionary}; } + /** + * @param {import('dictionary-data').TermMeta} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseTermMeta} + */ _convertTermMetaBankEntry(entry, dictionary) { const [expression, mode, data] = entry; - return {expression, mode, data, dictionary}; + return /** @type {import('dictionary-database').DatabaseTermMeta} */ ({expression, mode, data, dictionary}); } + /** + * @param {import('dictionary-data').KanjiV1} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseKanjiEntry} + */ _convertKanjiBankEntryV1(entry, dictionary) { const [character, onyomi, kunyomi, tags, ...meanings] = entry; return {character, onyomi, kunyomi, tags, meanings, dictionary}; } + /** + * @param {import('dictionary-data').KanjiV3} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseKanjiEntry} + */ _convertKanjiBankEntryV3(entry, dictionary) { const [character, onyomi, kunyomi, tags, meanings, stats] = entry; return {character, onyomi, kunyomi, tags, meanings, stats, dictionary}; } + /** + * @param {import('dictionary-data').KanjiMeta} entry + * @param {string} dictionary + * @returns {import('dictionary-database').DatabaseKanjiMeta} + */ _convertKanjiMetaBankEntry(entry, dictionary) { const [character, mode, data] = entry; return {character, mode, data, dictionary}; } + /** + * @param {import('dictionary-data').Tag} entry + * @param {string} dictionary + * @returns {import('dictionary-database').Tag} + */ _convertTagBankEntry(entry, dictionary) { const [name, category, order, notes, score] = entry; return {name, category, order, notes, score, dictionary}; } + /** + * @param {import('dictionary-data').Index} index + * @param {import('dictionary-database').Tag[]} results + * @param {string} dictionary + */ _addOldIndexTags(index, results, dictionary) { const {tagMeta} = index; if (typeof tagMeta !== 'object' || tagMeta === null) { return; } - for (const name of Object.keys(tagMeta)) { - const {category, order, notes, score} = tagMeta[name]; + for (const [name, value] of Object.entries(tagMeta)) { + const {category, order, notes, score} = value; results.push({name, category, order, notes, score, dictionary}); } } - _getArchiveFiles(zipEntriesObject, fileNameFormat) { + /** + * @param {import('dictionary-importer').ArchiveFileMap} fileMap + * @param {string} fileNameFormat + * @returns {import('@zip.js/zip.js').Entry[]} + */ + _getArchiveFiles(fileMap, fileNameFormat) { const indexPosition = fileNameFormat.indexOf('?'); const prefix = fileNameFormat.substring(0, indexPosition); const suffix = fileNameFormat.substring(indexPosition + 1); + /** @type {import('@zip.js/zip.js').Entry[]} */ const results = []; - for (const f of Object.keys(zipEntriesObject)) { - if (f.startsWith(prefix) && f.endsWith(suffix)) { - results.push(zipEntriesObject[f]); + for (const [name, value] of fileMap.entries()) { + if (name.startsWith(prefix) && name.endsWith(suffix)) { + results.push(value); } } return results; } + /** + * @template [TEntry=unknown] + * @template [TResult=unknown] + * @param {import('@zip.js/zip.js').Entry[]} files + * @param {(entry: TEntry, dictionaryTitle: string) => TResult} convertEntry + * @param {import('dictionary-importer').CompiledSchemaName} schemaName + * @param {string} dictionaryTitle + * @returns {Promise<TResult[]>} + */ async _readFileSequence(files, convertEntry, schemaName, dictionaryTitle) { const progressData = this._progressData; let startIndex = 0; const results = []; - for (const fileName of Object.keys(files)) { - const content = await files[fileName].getData( - new TextWriter() - ); - const entries = JSON.parse(content); + for (const file of files) { + const content = await this._getData(file, new TextWriter()); + const entries = /** @type {unknown} */ (JSON.parse(content)); startIndex = progressData.index; this._progress(); - if (!ajvSchemas[schemaName](entries)) { - throw this._formatAjvSchemaError(ajvSchemas[schemaName], fileName); + const schema = ajvSchemas[schemaName]; + if (!schema(entries)) { + throw this._formatAjvSchemaError(schema, file.filename); } progressData.index = startIndex + 1; this._progress(); - for (const entry of entries) { - results.push(convertEntry(entry, dictionaryTitle)); + if (Array.isArray(entries)) { + for (const entry of entries) { + results.push(convertEntry(/** @type {TEntry} */ (entry), dictionaryTitle)); + } } } return results; } + /** + * @param {import('dictionary-database').DatabaseTermMeta[]|import('dictionary-database').DatabaseKanjiMeta[]} metaList + * @returns {import('dictionary-importer').SummaryMetaCount} + */ _getMetaCounts(metaList) { + /** @type {Map<string, number>} */ const countsMap = new Map(); for (const {mode} of metaList) { let count = countsMap.get(mode); count = typeof count !== 'undefined' ? count + 1 : 1; countsMap.set(mode, count); } + /** @type {import('dictionary-importer').SummaryMetaCount} */ const counts = {total: metaList.length}; for (const [key, value] of countsMap.entries()) { if (Object.prototype.hasOwnProperty.call(counts, key)) { continue; } @@ -556,6 +773,10 @@ export class DictionaryImporter { return counts; } + /** + * @param {string} text + * @returns {string} + */ _normalizeTermOrReading(text) { // Note: this function should not perform String.normalize on the text, // as it will characters in an undesirable way. @@ -565,4 +786,17 @@ export class DictionaryImporter { // - '\ufa67'.normalize('NFC') => '\u9038' (逸 => 逸) return text; } + + /** + * @template [T=unknown] + * @param {import('@zip.js/zip.js').Entry} entry + * @param {import('@zip.js/zip.js').Writer<T>|import('@zip.js/zip.js').WritableWriter} writer + * @returns {Promise<T>} + */ + async _getData(entry, writer) { + if (typeof entry.getData === 'undefined') { + throw new Error(`Cannot read ${entry.filename}`); + } + return await entry.getData(writer); + } } diff --git a/ext/js/language/dictionary-worker-handler.js b/ext/js/language/dictionary-worker-handler.js index b8c41b26..9a724386 100644 --- a/ext/js/language/dictionary-worker-handler.js +++ b/ext/js/language/dictionary-worker-handler.js @@ -16,24 +16,29 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {serializeError} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {DictionaryDatabase} from './dictionary-database.js'; import {DictionaryImporter} from './dictionary-importer.js'; import {DictionaryWorkerMediaLoader} from './dictionary-worker-media-loader.js'; export class DictionaryWorkerHandler { constructor() { + /** @type {DictionaryWorkerMediaLoader} */ this._mediaLoader = new DictionaryWorkerMediaLoader(); } + /** */ prepare() { self.addEventListener('message', this._onMessage.bind(this), false); } // Private - _onMessage(e) { - const {action, params} = e.data; + /** + * @param {MessageEvent<import('dictionary-worker-handler').Message>} event + */ + _onMessage(event) { + const {action, params} = event.data; switch (action) { case 'importDictionary': this._onMessageWithProgress(params, this._importDictionary.bind(this)); @@ -50,7 +55,15 @@ export class DictionaryWorkerHandler { } } + /** + * @template [T=unknown] + * @param {T} params + * @param {(details: T, onProgress: import('dictionary-worker-handler').OnProgressCallback) => Promise<unknown>} handler + */ async _onMessageWithProgress(params, handler) { + /** + * @param {...unknown} args + */ const onProgress = (...args) => { self.postMessage({ action: 'progress', @@ -62,11 +75,16 @@ export class DictionaryWorkerHandler { const result = await handler(params, onProgress); response = {result}; } catch (e) { - response = {error: serializeError(e)}; + response = {error: ExtensionError.serialize(e)}; } self.postMessage({action: 'complete', params: response}); } + /** + * @param {import('dictionary-worker-handler').ImportDictionaryMessageParams} details + * @param {import('dictionary-worker-handler').OnProgressCallback} onProgress + * @returns {Promise<import('dictionary-worker').MessageCompleteResultSerialized>} + */ async _importDictionary({details, archiveContent}, onProgress) { const dictionaryDatabase = await this._getPreparedDictionaryDatabase(); try { @@ -74,13 +92,18 @@ export class DictionaryWorkerHandler { const {result, errors} = await dictionaryImporter.importDictionary(dictionaryDatabase, archiveContent, details); return { result, - errors: errors.map((error) => serializeError(error)) + errors: errors.map((error) => ExtensionError.serialize(error)) }; } finally { dictionaryDatabase.close(); } } + /** + * @param {import('dictionary-worker-handler').DeleteDictionaryMessageParams} details + * @param {import('dictionary-database').DeleteDictionaryProgressCallback} onProgress + * @returns {Promise<void>} + */ async _deleteDictionary({dictionaryTitle}, onProgress) { const dictionaryDatabase = await this._getPreparedDictionaryDatabase(); try { @@ -90,6 +113,10 @@ export class DictionaryWorkerHandler { } } + /** + * @param {import('dictionary-worker-handler').GetDictionaryCountsMessageParams} details + * @returns {Promise<import('dictionary-database').DictionaryCounts>} + */ async _getDictionaryCounts({dictionaryNames, getTotal}) { const dictionaryDatabase = await this._getPreparedDictionaryDatabase(); try { @@ -99,6 +126,9 @@ export class DictionaryWorkerHandler { } } + /** + * @returns {Promise<DictionaryDatabase>} + */ async _getPreparedDictionaryDatabase() { const dictionaryDatabase = new DictionaryDatabase(); await dictionaryDatabase.prepare(); diff --git a/ext/js/language/dictionary-worker-media-loader.js b/ext/js/language/dictionary-worker-media-loader.js index d58e46c5..e19a13d3 100644 --- a/ext/js/language/dictionary-worker-media-loader.js +++ b/ext/js/language/dictionary-worker-media-loader.js @@ -16,7 +16,8 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, generateId} from '../core.js'; +import {generateId} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; /** * Class used for loading and validating media from a worker thread @@ -27,12 +28,13 @@ export class DictionaryWorkerMediaLoader { * Creates a new instance of the media loader. */ constructor() { + /** @type {Map<string, {resolve: (result: import('dictionary-worker-media-loader').ImageDetails) => void, reject: (reason?: import('core').RejectionReason) => void}>} */ this._requests = new Map(); } /** * Handles a response message posted to the worker thread. - * @param {{id: string, error: object|undefined, result: any|undefined}} params Details of the response. + * @param {import('dictionary-worker-media-loader').HandleMessageParams} params Details of the response. */ handleMessage(params) { const {id} = params; @@ -41,24 +43,19 @@ export class DictionaryWorkerMediaLoader { this._requests.delete(id); const {error} = params; if (typeof error !== 'undefined') { - request.reject(deserializeError(error)); + request.reject(ExtensionError.deserialize(error)); } else { request.resolve(params.result); } } - /** - * Attempts to load an image using an ArrayBuffer and a media type to return details about it. - * @param {ArrayBuffer} content The binary content for the image, encoded as an ArrayBuffer. - * @param {string} mediaType The media type for the image content. - * @returns {Promise<{content: ArrayBuffer, width: number, height: number}>} Details about the requested image content. - * @throws {Error} An error can be thrown if the image fails to load. - */ + /** @type {import('dictionary-importer-media-loader').GetImageDetailsFunction} */ getImageDetails(content, mediaType) { return new Promise((resolve, reject) => { const id = generateId(16); this._requests.set(id, {resolve, reject}); - self.postMessage({ + // This is executed in a Worker context, so the self needs to be force cast + /** @type {Worker} */ (/** @type {unknown} */ (self)).postMessage({ action: 'getImageDetails', params: {id, content, mediaType} }, [content]); diff --git a/ext/js/language/dictionary-worker.js b/ext/js/language/dictionary-worker.js index 18c300af..3119dd7b 100644 --- a/ext/js/language/dictionary-worker.js +++ b/ext/js/language/dictionary-worker.js @@ -16,37 +16,65 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, serializeError} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; import {DictionaryImporterMediaLoader} from './dictionary-importer-media-loader.js'; export class DictionaryWorker { constructor() { + /** @type {DictionaryImporterMediaLoader} */ this._dictionaryImporterMediaLoader = new DictionaryImporterMediaLoader(); } + /** + * @param {ArrayBuffer} archiveContent + * @param {import('dictionary-importer').ImportDetails} details + * @param {?import('dictionary-worker').ImportProgressCallback} onProgress + * @returns {Promise<import('dictionary-importer').ImportResult>} + */ importDictionary(archiveContent, details, onProgress) { return this._invoke( 'importDictionary', {details, archiveContent}, [archiveContent], onProgress, - this._formatimportDictionaryResult.bind(this) + this._formatImportDictionaryResult.bind(this) ); } + /** + * @param {string} dictionaryTitle + * @param {?import('dictionary-worker').DeleteProgressCallback} onProgress + * @returns {Promise<void>} + */ deleteDictionary(dictionaryTitle, onProgress) { - return this._invoke('deleteDictionary', {dictionaryTitle}, [], onProgress); + return this._invoke('deleteDictionary', {dictionaryTitle}, [], onProgress, null); } + /** + * @param {string[]} dictionaryNames + * @param {boolean} getTotal + * @returns {Promise<import('dictionary-database').DictionaryCounts>} + */ getDictionaryCounts(dictionaryNames, getTotal) { - return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}, [], null); + return this._invoke('getDictionaryCounts', {dictionaryNames, getTotal}, [], null, null); } // Private + /** + * @template [TParams=import('core').SerializableObject] + * @template [TResponseRaw=unknown] + * @template [TResponse=unknown] + * @param {string} action + * @param {TParams} params + * @param {Transferable[]} transfer + * @param {?(arg: import('core').SafeAny) => void} onProgress + * @param {?(result: TResponseRaw) => TResponse} formatResult + */ _invoke(action, params, transfer, onProgress, formatResult) { return new Promise((resolve, reject) => { const worker = new Worker('/js/language/dictionary-worker-main.js', {type: 'module'}); + /** @type {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} */ const details = { complete: false, worker, @@ -56,20 +84,29 @@ export class DictionaryWorker { onProgress, formatResult }; - const onMessage = this._onMessage.bind(this, details); + // Ugly typecast below due to not being able to explicitly state the template types + /** @type {(event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */ + const onMessage = /** @type {(details: import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>, event: MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>) => void} */ (this._onMessage).bind(this, details); details.onMessage = onMessage; worker.addEventListener('message', onMessage); worker.postMessage({action, params}, transfer); }); } - _onMessage(details, e) { + /** + * @template [TResponseRaw=unknown] + * @template [TResponse=unknown] + * @param {import('dictionary-worker').InvokeDetails<TResponseRaw, TResponse>} details + * @param {MessageEvent<import('dictionary-worker').MessageData<TResponseRaw>>} event + */ + _onMessage(details, event) { if (details.complete) { return; } - const {action, params} = e.data; + const {action, params} = event.data; switch (action) { case 'complete': { const {worker, resolve, reject, onMessage, formatResult} = details; + if (worker === null || onMessage === null || resolve === null || reject === null) { return; } details.complete = true; details.worker = null; details.resolve = null; @@ -86,50 +123,84 @@ export class DictionaryWorker { this._onMessageProgress(params, details.onProgress); break; case 'getImageDetails': - this._onMessageGetImageDetails(params, details.worker); + { + const {worker} = details; + if (worker === null) { return; } + this._onMessageGetImageDetails(params, worker); + } break; } } + /** + * @template [TResponseRaw=unknown] + * @template [TResponse=unknown] + * @param {import('dictionary-worker').MessageCompleteParams<TResponseRaw>} params + * @param {(result: TResponse) => void} resolve + * @param {(reason?: import('core').RejectionReason) => void} reject + * @param {?(result: TResponseRaw) => TResponse} formatResult + */ _onMessageComplete(params, resolve, reject, formatResult) { const {error} = params; if (typeof error !== 'undefined') { - reject(deserializeError(error)); + reject(ExtensionError.deserialize(error)); } else { - let {result} = params; - try { - if (typeof formatResult === 'function') { - result = formatResult(result); + const {result} = params; + if (typeof formatResult === 'function') { + let result2; + try { + result2 = formatResult(result); + } catch (e) { + reject(e); + return; } - } catch (e) { - reject(e); - return; + resolve(result2); + } else { + // If formatResult is not provided, the response is assumed to be the same type + // For some reason, eslint thinks the TResponse type is undefined + // eslint-disable-next-line jsdoc/no-undefined-types + resolve(/** @type {TResponse} */ (/** @type {unknown} */ (result))); } - resolve(result); } } + /** + * @param {import('dictionary-worker').MessageProgressParams} params + * @param {?(...args: unknown[]) => void} onProgress + */ _onMessageProgress(params, onProgress) { if (typeof onProgress !== 'function') { return; } const {args} = params; onProgress(...args); } + /** + * @param {import('dictionary-worker').MessageGetImageDetailsParams} params + * @param {Worker} worker + */ async _onMessageGetImageDetails(params, worker) { const {id, content, mediaType} = params; + /** @type {Transferable[]} */ const transfer = []; let response; try { const result = await this._dictionaryImporterMediaLoader.getImageDetails(content, mediaType, transfer); response = {id, result}; } catch (e) { - response = {id, error: serializeError(e)}; + response = {id, error: ExtensionError.serialize(e)}; } worker.postMessage({action: 'getImageDetails.response', params: response}, transfer); } - _formatimportDictionaryResult(result) { - result.errors = result.errors.map((error) => deserializeError(error)); - return result; + /** + * @param {import('dictionary-worker').MessageCompleteResultSerialized} response + * @returns {import('dictionary-worker').MessageCompleteResult} + */ + _formatImportDictionaryResult(response) { + const {result, errors} = response; + return { + result, + errors: errors.map((error) => ExtensionError.deserialize(error)) + }; } } diff --git a/ext/js/language/sandbox/dictionary-data-util.js b/ext/js/language/sandbox/dictionary-data-util.js index 1b71346a..a54b043b 100644 --- a/ext/js/language/sandbox/dictionary-data-util.js +++ b/ext/js/language/sandbox/dictionary-data-util.js @@ -17,6 +17,10 @@ */ export class DictionaryDataUtil { + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('dictionary-data-util').TagGroup[]} + */ static groupTermTags(dictionaryEntry) { const {headwords} = dictionaryEntry; const headwordCount = headwords.length; @@ -27,8 +31,8 @@ export class DictionaryDataUtil { const {tags} = headwords[i]; for (const tag of tags) { if (uniqueCheck) { - const {name, category, notes, dictionary} = tag; - const key = this._createMapKey([name, category, notes, dictionary]); + const {name, category, content, dictionaries} = tag; + const key = this._createMapKey([name, category, content, dictionaries]); const index = resultsIndexMap.get(key); if (typeof index !== 'undefined') { const existingItem = results[index]; @@ -45,11 +49,16 @@ export class DictionaryDataUtil { return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').TermFrequency>[]} + */ static groupTermFrequencies(dictionaryEntry) { - const {headwords, frequencies} = dictionaryEntry; + const {headwords, frequencies: sourceFrequencies} = dictionaryEntry; + /** @type {import('dictionary-data-util').TermFrequenciesMap1} */ const map1 = new Map(); - for (const {headwordIndex, dictionary, hasReading, frequency, displayValue} of frequencies) { + for (const {headwordIndex, dictionary, hasReading, frequency, displayValue} of sourceFrequencies) { const {term, reading} = headwords[headwordIndex]; let map2 = map1.get(dictionary); @@ -68,12 +77,30 @@ export class DictionaryDataUtil { frequencyData.values.set(this._createMapKey([frequency, displayValue]), {frequency, displayValue}); } - return this._createFrequencyGroupsFromMap(map1); + + const results = []; + for (const [dictionary, map2] of map1.entries()) { + const frequencies = []; + for (const {term, reading, values} of map2.values()) { + frequencies.push({ + term, + reading, + values: [...values.values()] + }); + } + results.push({dictionary, frequencies}); + } + return results; } - static groupKanjiFrequencies(frequencies) { + /** + * @param {import('dictionary').KanjiFrequency[]} sourceFrequencies + * @returns {import('dictionary-data-util').DictionaryFrequency<import('dictionary-data-util').KanjiFrequency>[]} + */ + static groupKanjiFrequencies(sourceFrequencies) { + /** @type {import('dictionary-data-util').KanjiFrequenciesMap1} */ const map1 = new Map(); - for (const {dictionary, character, frequency, displayValue} of frequencies) { + for (const {dictionary, character, frequency, displayValue} of sourceFrequencies) { let map2 = map1.get(dictionary); if (typeof map2 === 'undefined') { map2 = new Map(); @@ -88,9 +115,25 @@ export class DictionaryDataUtil { frequencyData.values.set(this._createMapKey([frequency, displayValue]), {frequency, displayValue}); } - return this._createFrequencyGroupsFromMap(map1); + + const results = []; + for (const [dictionary, map2] of map1.entries()) { + const frequencies = []; + for (const {character, values} of map2.values()) { + frequencies.push({ + character, + values: [...values.values()] + }); + } + results.push({dictionary, frequencies}); + } + return results; } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + * @returns {import('dictionary-data-util').DictionaryGroupedPronunciations[]} + */ static getGroupedPronunciations(dictionaryEntry) { const {headwords, pronunciations} = dictionaryEntry; @@ -101,6 +144,7 @@ export class DictionaryDataUtil { allReadings.add(reading); } + /** @type {Map<string, import('dictionary-data-util').GroupedPronunciationInternal[]>} */ const groupedPronunciationsMap = new Map(); for (const {headwordIndex, dictionary, pitches} of pronunciations) { const {term, reading} = headwords[headwordIndex]; @@ -118,9 +162,7 @@ export class DictionaryDataUtil { position, nasalPositions, devoicePositions, - tags, - exclusiveTerms: [], - exclusiveReadings: [] + tags }; dictionaryGroupedPronunciationList.push(groupedPronunciation); } @@ -128,27 +170,39 @@ export class DictionaryDataUtil { } } + /** @type {import('dictionary-data-util').DictionaryGroupedPronunciations[]} */ + const results2 = []; const multipleReadings = (allReadings.size > 1); - for (const dictionaryGroupedPronunciationList of groupedPronunciationsMap.values()) { + for (const [dictionary, dictionaryGroupedPronunciationList] of groupedPronunciationsMap.entries()) { + /** @type {import('dictionary-data-util').GroupedPronunciation[]} */ + const pronunciations2 = []; for (const groupedPronunciation of dictionaryGroupedPronunciationList) { - const {terms, reading, exclusiveTerms, exclusiveReadings} = groupedPronunciation; - if (!this._areSetsEqual(terms, allTerms)) { - exclusiveTerms.push(...this._getSetIntersection(terms, allTerms)); - } + const {terms, reading, position, nasalPositions, devoicePositions, tags} = groupedPronunciation; + const exclusiveTerms = !this._areSetsEqual(terms, allTerms) ? this._getSetIntersection(terms, allTerms) : []; + const exclusiveReadings = []; if (multipleReadings) { exclusiveReadings.push(reading); } - groupedPronunciation.terms = [...terms]; + pronunciations2.push({ + terms: [...terms], + reading, + position, + nasalPositions, + devoicePositions, + tags, + exclusiveTerms, + exclusiveReadings + }); } - } - - const results2 = []; - for (const [dictionary, pronunciations2] of groupedPronunciationsMap.entries()) { results2.push({dictionary, pronunciations: pronunciations2}); } return results2; } + /** + * @param {import('dictionary').Tag[]|import('anki-templates').Tag[]} termTags + * @returns {import('dictionary-data-util').TermFrequencyType} + */ static getTermFrequency(termTags) { let totalScore = 0; for (const {score} of termTags) { @@ -163,10 +217,19 @@ export class DictionaryDataUtil { } } + /** + * @param {import('dictionary').TermHeadword[]} headwords + * @param {number[]} headwordIndices + * @param {Set<string>} allTermsSet + * @param {Set<string>} allReadingsSet + * @returns {string[]} + */ static getDisambiguations(headwords, headwordIndices, allTermsSet, allReadingsSet) { if (allTermsSet.size <= 1 && allReadingsSet.size <= 1) { return []; } + /** @type {Set<string>} */ const terms = new Set(); + /** @type {Set<string>} */ const readings = new Set(); for (const headwordIndex of headwordIndices) { const {term, reading} = headwords[headwordIndex]; @@ -174,6 +237,7 @@ export class DictionaryDataUtil { readings.add(reading); } + /** @type {string[]} */ const disambiguations = []; const addTerms = !this._areSetsEqual(terms, allTermsSet); const addReadings = !this._areSetsEqual(readings, allReadingsSet); @@ -191,6 +255,10 @@ export class DictionaryDataUtil { return disambiguations; } + /** + * @param {string[]} wordClasses + * @returns {boolean} + */ static isNonNounVerbOrAdjective(wordClasses) { let isVerbOrAdjective = false; let isSuruVerb = false; @@ -218,19 +286,15 @@ export class DictionaryDataUtil { // Private - static _createFrequencyGroupsFromMap(map) { - const results = []; - for (const [dictionary, map2] of map.entries()) { - const frequencies = []; - for (const frequencyData of map2.values()) { - frequencyData.values = [...frequencyData.values.values()]; - frequencies.push(frequencyData); - } - results.push({dictionary, frequencies}); - } - return results; - } - + /** + * @param {string} reading + * @param {number} position + * @param {number[]} nasalPositions + * @param {number[]} devoicePositions + * @param {import('dictionary').Tag[]} tags + * @param {import('dictionary-data-util').GroupedPronunciationInternal[]} groupedPronunciationList + * @returns {?import('dictionary-data-util').GroupedPronunciationInternal} + */ static _findExistingGroupedPronunciation(reading, position, nasalPositions, devoicePositions, tags, groupedPronunciationList) { for (const pitchInfo of groupedPronunciationList) { if ( @@ -246,6 +310,12 @@ export class DictionaryDataUtil { return null; } + /** + * @template [T=unknown] + * @param {T[]} array1 + * @param {T[]} array2 + * @returns {boolean} + */ static _areArraysEqual(array1, array2) { const ii = array1.length; if (ii !== array2.length) { return false; } @@ -255,6 +325,11 @@ export class DictionaryDataUtil { return true; } + /** + * @param {import('dictionary').Tag[]} tagList1 + * @param {import('dictionary').Tag[]} tagList2 + * @returns {boolean} + */ static _areTagListsEqual(tagList1, tagList2) { const ii = tagList1.length; if (tagList2.length !== ii) { return false; } @@ -262,7 +337,7 @@ export class DictionaryDataUtil { for (let i = 0; i < ii; ++i) { const tag1 = tagList1[i]; const tag2 = tagList2[i]; - if (tag1.name !== tag2.name || tag1.dictionary !== tag2.dictionary) { + if (tag1.name !== tag2.name || !this._areArraysEqual(tag1.dictionaries, tag2.dictionaries)) { return false; } } @@ -270,6 +345,12 @@ export class DictionaryDataUtil { return true; } + /** + * @template [T=unknown] + * @param {Set<T>} set1 + * @param {Set<T>} set2 + * @returns {boolean} + */ static _areSetsEqual(set1, set2) { if (set1.size !== set2.size) { return false; @@ -284,6 +365,12 @@ export class DictionaryDataUtil { return true; } + /** + * @template [T=unknown] + * @param {Set<T>} set1 + * @param {Set<T>} set2 + * @returns {T[]} + */ static _getSetIntersection(set1, set2) { const result = []; for (const value of set1) { @@ -294,6 +381,10 @@ export class DictionaryDataUtil { return result; } + /** + * @param {unknown[]} array + * @returns {string} + */ static _createMapKey(array) { return JSON.stringify(array); } diff --git a/ext/js/language/sandbox/japanese-util.js b/ext/js/language/sandbox/japanese-util.js index 316b1c2e..6f4fc8e0 100644 --- a/ext/js/language/sandbox/japanese-util.js +++ b/ext/js/language/sandbox/japanese-util.js @@ -16,710 +16,877 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -export const JapaneseUtil = (() => { - const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; - const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; - const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5; - const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6; - const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; - - const HIRAGANA_RANGE = [0x3040, 0x309f]; - const KATAKANA_RANGE = [0x30a0, 0x30ff]; - - const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096]; - const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6]; - - const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; - - const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf]; - const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef]; - const CJK_COMPATIBILITY_IDEOGRAPHS_RANGE = [0xf900, 0xfaff]; - const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f]; - const CJK_IDEOGRAPH_RANGES = [ - CJK_UNIFIED_IDEOGRAPHS_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE, - CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE, - CJK_COMPATIBILITY_IDEOGRAPHS_RANGE, - CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE - ]; - - // Japanese character ranges, roughly ordered in order of expected frequency - const JAPANESE_RANGES = [ - HIRAGANA_RANGE, - KATAKANA_RANGE, - - ...CJK_IDEOGRAPH_RANGES, - - [0xff66, 0xff9f], // Halfwidth katakana - - [0x30fb, 0x30fc], // Katakana punctuation - [0xff61, 0xff65], // Kana punctuation - [0x3000, 0x303f], // CJK punctuation - - [0xff10, 0xff19], // Fullwidth numbers - [0xff21, 0xff3a], // Fullwidth upper case Latin letters - [0xff41, 0xff5a], // Fullwidth lower case Latin letters - - [0xff01, 0xff0f], // Fullwidth punctuation 1 - [0xff1a, 0xff1f], // Fullwidth punctuation 2 - [0xff3b, 0xff3f], // Fullwidth punctuation 3 - [0xff5b, 0xff60], // Fullwidth punctuation 4 - [0xffe0, 0xffee] // Currency markers - ]; - - const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); - - const HALFWIDTH_KATAKANA_MAPPING = new Map([ - ['ヲ', 'ヲヺ-'], - ['ァ', 'ァ--'], - ['ィ', 'ィ--'], - ['ゥ', 'ゥ--'], - ['ェ', 'ェ--'], - ['ォ', 'ォ--'], - ['ャ', 'ャ--'], - ['ュ', 'ュ--'], - ['ョ', 'ョ--'], - ['ッ', 'ッ--'], - ['ー', 'ー--'], - ['ア', 'ア--'], - ['イ', 'イ--'], - ['ウ', 'ウヴ-'], - ['エ', 'エ--'], - ['オ', 'オ--'], - ['カ', 'カガ-'], - ['キ', 'キギ-'], - ['ク', 'クグ-'], - ['ケ', 'ケゲ-'], - ['コ', 'コゴ-'], - ['サ', 'サザ-'], - ['シ', 'シジ-'], - ['ス', 'スズ-'], - ['セ', 'セゼ-'], - ['ソ', 'ソゾ-'], - ['タ', 'タダ-'], - ['チ', 'チヂ-'], - ['ツ', 'ツヅ-'], - ['テ', 'テデ-'], - ['ト', 'トド-'], - ['ナ', 'ナ--'], - ['ニ', 'ニ--'], - ['ヌ', 'ヌ--'], - ['ネ', 'ネ--'], - ['ノ', 'ノ--'], - ['ハ', 'ハバパ'], - ['ヒ', 'ヒビピ'], - ['フ', 'フブプ'], - ['ヘ', 'ヘベペ'], - ['ホ', 'ホボポ'], - ['マ', 'マ--'], - ['ミ', 'ミ--'], - ['ム', 'ム--'], - ['メ', 'メ--'], - ['モ', 'モ--'], - ['ヤ', 'ヤ--'], - ['ユ', 'ユ--'], - ['ヨ', 'ヨ--'], - ['ラ', 'ラ--'], - ['リ', 'リ--'], - ['ル', 'ル--'], - ['レ', 'レ--'], - ['ロ', 'ロ--'], - ['ワ', 'ワ--'], - ['ン', 'ン--'] - ]); - - const VOWEL_TO_KANA_MAPPING = new Map([ - ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'], - ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'], - ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'], - ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'], - ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'], - ['', 'のノ'] - ]); - - const KANA_TO_VOWEL_MAPPING = (() => { - const map = new Map(); - for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) { - for (const character of characters) { - map.set(character, vowel); - } - } - return map; - })(); - - const DIACRITIC_MAPPING = (() => { - const kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ'; - const map = new Map(); - for (let i = 0, ii = kana.length; i < ii; i += 3) { - const character = kana[i]; - const dakuten = kana[i + 1]; - const handakuten = kana[i + 2]; - map.set(dakuten, {character, type: 'dakuten'}); - if (handakuten !== '-') { - map.set(handakuten, {character, type: 'handakuten'}); - } +const HIRAGANA_SMALL_TSU_CODE_POINT = 0x3063; +const KATAKANA_SMALL_TSU_CODE_POINT = 0x30c3; +const KATAKANA_SMALL_KA_CODE_POINT = 0x30f5; +const KATAKANA_SMALL_KE_CODE_POINT = 0x30f6; +const KANA_PROLONGED_SOUND_MARK_CODE_POINT = 0x30fc; + +/** @type {import('japanese-util').CodepointRange} */ +const HIRAGANA_RANGE = [0x3040, 0x309f]; +/** @type {import('japanese-util').CodepointRange} */ +const KATAKANA_RANGE = [0x30a0, 0x30ff]; + +/** @type {import('japanese-util').CodepointRange} */ +const HIRAGANA_CONVERSION_RANGE = [0x3041, 0x3096]; +/** @type {import('japanese-util').CodepointRange} */ +const KATAKANA_CONVERSION_RANGE = [0x30a1, 0x30f6]; + +/** @type {import('japanese-util').CodepointRange[]} */ +const KANA_RANGES = [HIRAGANA_RANGE, KATAKANA_RANGE]; + +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_RANGE = [0x4e00, 0x9fff]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE = [0x3400, 0x4dbf]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE = [0x20000, 0x2a6df]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE = [0x2a700, 0x2b73f]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE = [0x2b740, 0x2b81f]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE = [0x2b820, 0x2ceaf]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE = [0x2ceb0, 0x2ebef]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_COMPATIBILITY_IDEOGRAPHS_RANGE = [0xf900, 0xfaff]; +/** @type {import('japanese-util').CodepointRange} */ +const CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE = [0x2f800, 0x2fa1f]; +/** @type {import('japanese-util').CodepointRange[]} */ +const CJK_IDEOGRAPH_RANGES = [ + CJK_UNIFIED_IDEOGRAPHS_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_A_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_B_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_C_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_D_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_E_RANGE, + CJK_UNIFIED_IDEOGRAPHS_EXTENSION_F_RANGE, + CJK_COMPATIBILITY_IDEOGRAPHS_RANGE, + CJK_COMPATIBILITY_IDEOGRAPHS_SUPPLEMENT_RANGE +]; + +// Japanese character ranges, roughly ordered in order of expected frequency +/** @type {import('japanese-util').CodepointRange[]} */ +const JAPANESE_RANGES = [ + HIRAGANA_RANGE, + KATAKANA_RANGE, + + ...CJK_IDEOGRAPH_RANGES, + + [0xff66, 0xff9f], // Halfwidth katakana + + [0x30fb, 0x30fc], // Katakana punctuation + [0xff61, 0xff65], // Kana punctuation + [0x3000, 0x303f], // CJK punctuation + + [0xff10, 0xff19], // Fullwidth numbers + [0xff21, 0xff3a], // Fullwidth upper case Latin letters + [0xff41, 0xff5a], // Fullwidth lower case Latin letters + + [0xff01, 0xff0f], // Fullwidth punctuation 1 + [0xff1a, 0xff1f], // Fullwidth punctuation 2 + [0xff3b, 0xff3f], // Fullwidth punctuation 3 + [0xff5b, 0xff60], // Fullwidth punctuation 4 + [0xffe0, 0xffee] // Currency markers +]; + +const SMALL_KANA_SET = new Set(Array.from('ぁぃぅぇぉゃゅょゎァィゥェォャュョヮ')); + +const HALFWIDTH_KATAKANA_MAPPING = new Map([ + ['ヲ', 'ヲヺ-'], + ['ァ', 'ァ--'], + ['ィ', 'ィ--'], + ['ゥ', 'ゥ--'], + ['ェ', 'ェ--'], + ['ォ', 'ォ--'], + ['ャ', 'ャ--'], + ['ュ', 'ュ--'], + ['ョ', 'ョ--'], + ['ッ', 'ッ--'], + ['ー', 'ー--'], + ['ア', 'ア--'], + ['イ', 'イ--'], + ['ウ', 'ウヴ-'], + ['エ', 'エ--'], + ['オ', 'オ--'], + ['カ', 'カガ-'], + ['キ', 'キギ-'], + ['ク', 'クグ-'], + ['ケ', 'ケゲ-'], + ['コ', 'コゴ-'], + ['サ', 'サザ-'], + ['シ', 'シジ-'], + ['ス', 'スズ-'], + ['セ', 'セゼ-'], + ['ソ', 'ソゾ-'], + ['タ', 'タダ-'], + ['チ', 'チヂ-'], + ['ツ', 'ツヅ-'], + ['テ', 'テデ-'], + ['ト', 'トド-'], + ['ナ', 'ナ--'], + ['ニ', 'ニ--'], + ['ヌ', 'ヌ--'], + ['ネ', 'ネ--'], + ['ノ', 'ノ--'], + ['ハ', 'ハバパ'], + ['ヒ', 'ヒビピ'], + ['フ', 'フブプ'], + ['ヘ', 'ヘベペ'], + ['ホ', 'ホボポ'], + ['マ', 'マ--'], + ['ミ', 'ミ--'], + ['ム', 'ム--'], + ['メ', 'メ--'], + ['モ', 'モ--'], + ['ヤ', 'ヤ--'], + ['ユ', 'ユ--'], + ['ヨ', 'ヨ--'], + ['ラ', 'ラ--'], + ['リ', 'リ--'], + ['ル', 'ル--'], + ['レ', 'レ--'], + ['ロ', 'ロ--'], + ['ワ', 'ワ--'], + ['ン', 'ン--'] +]); + +const VOWEL_TO_KANA_MAPPING = new Map([ + ['a', 'ぁあかがさざただなはばぱまゃやらゎわヵァアカガサザタダナハバパマャヤラヮワヵヷ'], + ['i', 'ぃいきぎしじちぢにひびぴみりゐィイキギシジチヂニヒビピミリヰヸ'], + ['u', 'ぅうくぐすずっつづぬふぶぷむゅゆるゥウクグスズッツヅヌフブプムュユルヴ'], + ['e', 'ぇえけげせぜてでねへべぺめれゑヶェエケゲセゼテデネヘベペメレヱヶヹ'], + ['o', 'ぉおこごそぞとどのほぼぽもょよろをォオコゴソゾトドノホボポモョヨロヲヺ'], + ['', 'のノ'] +]); + +const KANA_TO_VOWEL_MAPPING = (() => { + /** @type {Map<string, string>} */ + const map = new Map(); + for (const [vowel, characters] of VOWEL_TO_KANA_MAPPING) { + for (const character of characters) { + map.set(character, vowel); } - return map; - })(); - - - function isCodePointInRange(codePoint, [min, max]) { - return (codePoint >= min && codePoint <= max); } + return map; +})(); - function isCodePointInRanges(codePoint, ranges) { - for (const [min, max] of ranges) { - if (codePoint >= min && codePoint <= max) { - return true; - } +const DIACRITIC_MAPPING = (() => { + const kana = 'うゔ-かが-きぎ-くぐ-けげ-こご-さざ-しじ-すず-せぜ-そぞ-ただ-ちぢ-つづ-てで-とど-はばぱひびぴふぶぷへべぺほぼぽワヷ-ヰヸ-ウヴ-ヱヹ-ヲヺ-カガ-キギ-クグ-ケゲ-コゴ-サザ-シジ-スズ-セゼ-ソゾ-タダ-チヂ-ツヅ-テデ-トド-ハバパヒビピフブプヘベペホボポ'; + /** @type {Map<string, {character: string, type: import('japanese-util').DiacriticType}>} */ + const map = new Map(); + for (let i = 0, ii = kana.length; i < ii; i += 3) { + const character = kana[i]; + const dakuten = kana[i + 1]; + const handakuten = kana[i + 2]; + map.set(dakuten, {character, type: 'dakuten'}); + if (handakuten !== '-') { + map.set(handakuten, {character, type: 'handakuten'}); } - return false; } + return map; +})(); + - function getProlongedHiragana(previousCharacter) { - switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) { - case 'a': return 'あ'; - case 'i': return 'い'; - case 'u': return 'う'; - case 'e': return 'え'; - case 'o': return 'う'; - default: return null; +/** + * @param {number} codePoint + * @param {import('japanese-util').CodepointRange} range + * @returns {boolean} + */ +// eslint-disable-next-line no-implicit-globals +function isCodePointInRange(codePoint, [min, max]) { + return (codePoint >= min && codePoint <= max); +} + +/** + * @param {number} codePoint + * @param {import('japanese-util').CodepointRange[]} ranges + * @returns {boolean} + */ +// eslint-disable-next-line no-implicit-globals +function isCodePointInRanges(codePoint, ranges) { + for (const [min, max] of ranges) { + if (codePoint >= min && codePoint <= max) { + return true; } } + return false; +} +/** + * @param {string} previousCharacter + * @returns {?string} + */ +// eslint-disable-next-line no-implicit-globals +function getProlongedHiragana(previousCharacter) { + switch (KANA_TO_VOWEL_MAPPING.get(previousCharacter)) { + case 'a': return 'あ'; + case 'i': return 'い'; + case 'u': return 'う'; + case 'e': return 'え'; + case 'o': return 'う'; + default: return null; + } +} - // eslint-disable-next-line no-shadow - class JapaneseUtil { - constructor(wanakana=null) { - this._wanakana = wanakana; - } - // Character code testing functions +export class JapaneseUtil { + /** + * @param {?import('wanakana')|import('../../../lib/wanakana.js')} wanakana + */ + constructor(wanakana=null) { + /** @type {?import('wanakana')} */ + this._wanakana = /** @type {import('wanakana')} */ (wanakana); + } - isCodePointKanji(codePoint) { - return isCodePointInRanges(codePoint, CJK_IDEOGRAPH_RANGES); - } + // Character code testing functions - isCodePointKana(codePoint) { - return isCodePointInRanges(codePoint, KANA_RANGES); - } + /** + * @param {number} codePoint + * @returns {boolean} + */ + isCodePointKanji(codePoint) { + return isCodePointInRanges(codePoint, CJK_IDEOGRAPH_RANGES); + } - isCodePointJapanese(codePoint) { - return isCodePointInRanges(codePoint, JAPANESE_RANGES); - } + /** + * @param {number} codePoint + * @returns {boolean} + */ + isCodePointKana(codePoint) { + return isCodePointInRanges(codePoint, KANA_RANGES); + } - // String testing functions + /** + * @param {number} codePoint + * @returns {boolean} + */ + isCodePointJapanese(codePoint) { + return isCodePointInRanges(codePoint, JAPANESE_RANGES); + } - isStringEntirelyKana(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (!isCodePointInRanges(c.codePointAt(0), KANA_RANGES)) { - return false; - } + // String testing functions + + /** + * @param {string} str + * @returns {boolean} + */ + isStringEntirelyKana(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (!isCodePointInRanges(/** @type {number} */ (c.codePointAt(0)), KANA_RANGES)) { + return false; } - return true; } + return true; + } - isStringPartiallyJapanese(str) { - if (str.length === 0) { return false; } - for (const c of str) { - if (isCodePointInRanges(c.codePointAt(0), JAPANESE_RANGES)) { - return true; - } + /** + * @param {string} str + * @returns {boolean} + */ + isStringPartiallyJapanese(str) { + if (str.length === 0) { return false; } + for (const c of str) { + if (isCodePointInRanges(/** @type {number} */ (c.codePointAt(0)), JAPANESE_RANGES)) { + return true; } - return false; } + return false; + } - // Mora functions + // Mora functions - isMoraPitchHigh(moraIndex, pitchAccentDownstepPosition) { - switch (pitchAccentDownstepPosition) { - case 0: return (moraIndex > 0); - case 1: return (moraIndex < 1); - default: return (moraIndex > 0 && moraIndex < pitchAccentDownstepPosition); - } + /** + * @param {number} moraIndex + * @param {number} pitchAccentDownstepPosition + * @returns {boolean} + */ + isMoraPitchHigh(moraIndex, pitchAccentDownstepPosition) { + switch (pitchAccentDownstepPosition) { + case 0: return (moraIndex > 0); + case 1: return (moraIndex < 1); + default: return (moraIndex > 0 && moraIndex < pitchAccentDownstepPosition); } + } - getPitchCategory(text, pitchAccentDownstepPosition, isVerbOrAdjective) { - if (pitchAccentDownstepPosition === 0) { - return 'heiban'; - } - if (isVerbOrAdjective) { - return pitchAccentDownstepPosition > 0 ? 'kifuku' : null; - } - if (pitchAccentDownstepPosition === 1) { - return 'atamadaka'; - } - if (pitchAccentDownstepPosition > 1) { - return pitchAccentDownstepPosition >= this.getKanaMoraCount(text) ? 'odaka' : 'nakadaka'; - } - return null; + /** + * @param {string} text + * @param {number} pitchAccentDownstepPosition + * @param {boolean} isVerbOrAdjective + * @returns {?import('japanese-util').PitchCategory} + */ + getPitchCategory(text, pitchAccentDownstepPosition, isVerbOrAdjective) { + if (pitchAccentDownstepPosition === 0) { + return 'heiban'; + } + if (isVerbOrAdjective) { + return pitchAccentDownstepPosition > 0 ? 'kifuku' : null; + } + if (pitchAccentDownstepPosition === 1) { + return 'atamadaka'; } + if (pitchAccentDownstepPosition > 1) { + return pitchAccentDownstepPosition >= this.getKanaMoraCount(text) ? 'odaka' : 'nakadaka'; + } + return null; + } - getKanaMorae(text) { - const morae = []; - let i; - for (const c of text) { - if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { - morae[i - 1] += c; - } else { - morae.push(c); - } + /** + * @param {string} text + * @returns {string[]} + */ + getKanaMorae(text) { + const morae = []; + let i; + for (const c of text) { + if (SMALL_KANA_SET.has(c) && (i = morae.length) > 0) { + morae[i - 1] += c; + } else { + morae.push(c); } - return morae; } + return morae; + } - getKanaMoraCount(text) { - let moraCount = 0; - for (const c of text) { - if (!(SMALL_KANA_SET.has(c) && moraCount > 0)) { - ++moraCount; - } + /** + * @param {string} text + * @returns {number} + */ + getKanaMoraCount(text) { + let moraCount = 0; + for (const c of text) { + if (!(SMALL_KANA_SET.has(c) && moraCount > 0)) { + ++moraCount; } - return moraCount; } + return moraCount; + } - // Conversion functions + // Conversion functions - convertToKana(text) { - return this._getWanakana().toKana(text); - } + /** + * @param {string} text + * @returns {string} + */ + convertToKana(text) { + return this._getWanakana().toKana(text); + } - convertToKanaSupported() { - return this._wanakana !== null; - } + /** + * @returns {boolean} + */ + convertToKanaSupported() { + return this._wanakana !== null; + } - convertKatakanaToHiragana(text, keepProlongedSoundMarks=false) { - let result = ''; - const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]); - for (let char of text) { - const codePoint = char.codePointAt(0); - switch (codePoint) { - case KATAKANA_SMALL_KA_CODE_POINT: - case KATAKANA_SMALL_KE_CODE_POINT: - // No change - break; - case KANA_PROLONGED_SOUND_MARK_CODE_POINT: - if (!keepProlongedSoundMarks && result.length > 0) { - const char2 = getProlongedHiragana(result[result.length - 1]); - if (char2 !== null) { char = char2; } - } - break; - default: - if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) { - char = String.fromCodePoint(codePoint + offset); - } - break; - } - result += char; + /** + * @param {string} text + * @param {boolean} [keepProlongedSoundMarks] + * @returns {string} + */ + convertKatakanaToHiragana(text, keepProlongedSoundMarks=false) { + let result = ''; + const offset = (HIRAGANA_CONVERSION_RANGE[0] - KATAKANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = /** @type {number} */ (char.codePointAt(0)); + switch (codePoint) { + case KATAKANA_SMALL_KA_CODE_POINT: + case KATAKANA_SMALL_KE_CODE_POINT: + // No change + break; + case KANA_PROLONGED_SOUND_MARK_CODE_POINT: + if (!keepProlongedSoundMarks && result.length > 0) { + const char2 = getProlongedHiragana(result[result.length - 1]); + if (char2 !== null) { char = char2; } + } + break; + default: + if (isCodePointInRange(codePoint, KATAKANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); + } + break; } - return result; + result += char; } + return result; + } - convertHiraganaToKatakana(text) { - let result = ''; - const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]); - for (let char of text) { - const codePoint = char.codePointAt(0); - if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) { - char = String.fromCodePoint(codePoint + offset); - } - result += char; + /** + * @param {string} text + * @returns {string} + */ + convertHiraganaToKatakana(text) { + let result = ''; + const offset = (KATAKANA_CONVERSION_RANGE[0] - HIRAGANA_CONVERSION_RANGE[0]); + for (let char of text) { + const codePoint = /** @type {number} */ (char.codePointAt(0)); + if (isCodePointInRange(codePoint, HIRAGANA_CONVERSION_RANGE)) { + char = String.fromCodePoint(codePoint + offset); } - return result; + result += char; } + return result; + } - convertToRomaji(text) { - const wanakana = this._getWanakana(); - return wanakana.toRomaji(text); - } + /** + * @param {string} text + * @returns {string} + */ + convertToRomaji(text) { + const wanakana = this._getWanakana(); + return wanakana.toRomaji(text); + } - convertToRomajiSupported() { - return this._wanakana !== null; - } + /** + * @returns {boolean} + */ + convertToRomajiSupported() { + return this._wanakana !== null; + } - convertNumericToFullWidth(text) { - let result = ''; - for (const char of text) { - let c = char.codePointAt(0); - if (c >= 0x30 && c <= 0x39) { // ['0', '9'] - c += 0xff10 - 0x30; // 0xff10 = '0' full width - result += String.fromCodePoint(c); - } else { - result += char; - } + /** + * @param {string} text + * @returns {string} + */ + convertNumericToFullWidth(text) { + let result = ''; + for (const char of text) { + let c = /** @type {number} */ (char.codePointAt(0)); + if (c >= 0x30 && c <= 0x39) { // ['0', '9'] + c += 0xff10 - 0x30; // 0xff10 = '0' full width + result += String.fromCodePoint(c); + } else { + result += char; } - return result; } + return result; + } - convertHalfWidthKanaToFullWidth(text, sourceMap=null) { - let result = ''; - - // This function is safe to use charCodeAt instead of codePointAt, since all - // the relevant characters are represented with a single UTF-16 character code. - for (let i = 0, ii = text.length; i < ii; ++i) { - const c = text[i]; - const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c); - if (typeof mapping !== 'string') { - result += c; - continue; - } - - let index = 0; - switch (text.charCodeAt(i + 1)) { - case 0xff9e: // dakuten - index = 1; - break; - case 0xff9f: // handakuten - index = 2; - break; - } - - let c2 = mapping[index]; - if (index > 0) { - if (c2 === '-') { // invalid - index = 0; - c2 = mapping[0]; - } else { - ++i; - } - } - - if (sourceMap !== null && index > 0) { - sourceMap.combine(result.length, 1); - } - result += c2; + /** + * @param {string} text + * @param {?import('../../general/text-source-map.js').TextSourceMap} [sourceMap] + * @returns {string} + */ + convertHalfWidthKanaToFullWidth(text, sourceMap=null) { + let result = ''; + + // This function is safe to use charCodeAt instead of codePointAt, since all + // the relevant characters are represented with a single UTF-16 character code. + for (let i = 0, ii = text.length; i < ii; ++i) { + const c = text[i]; + const mapping = HALFWIDTH_KATAKANA_MAPPING.get(c); + if (typeof mapping !== 'string') { + result += c; + continue; } - return result; - } + let index = 0; + switch (text.charCodeAt(i + 1)) { + case 0xff9e: // dakuten + index = 1; + break; + case 0xff9f: // handakuten + index = 2; + break; + } - convertAlphabeticToKana(text, sourceMap=null) { - let part = ''; - let result = ''; - - for (const char of text) { - // Note: 0x61 is the character code for 'a' - let c = char.codePointAt(0); - if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] - c += (0x61 - 0x41); - } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] - // NOP; c += (0x61 - 0x61); - } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth - c += (0x61 - 0xff21); - } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth - c += (0x61 - 0xff41); - } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash - c = 0x2d; // '-' + let c2 = mapping[index]; + if (index > 0) { + if (c2 === '-') { // invalid + index = 0; + c2 = mapping[0]; } else { - if (part.length > 0) { - result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); - part = ''; - } - result += char; - continue; + ++i; } - part += String.fromCodePoint(c); } - if (part.length > 0) { - result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + if (sourceMap !== null && index > 0) { + sourceMap.combine(result.length, 1); } - return result; + result += c2; } - convertAlphabeticToKanaSupported() { - return this._wanakana !== null; + return result; + } + + /** + * @param {string} text + * @param {?import('../../general/text-source-map.js').TextSourceMap} sourceMap + * @returns {string} + */ + convertAlphabeticToKana(text, sourceMap=null) { + let part = ''; + let result = ''; + + for (const char of text) { + // Note: 0x61 is the character code for 'a' + let c = /** @type {number} */ (char.codePointAt(0)); + if (c >= 0x41 && c <= 0x5a) { // ['A', 'Z'] + c += (0x61 - 0x41); + } else if (c >= 0x61 && c <= 0x7a) { // ['a', 'z'] + // NOP; c += (0x61 - 0x61); + } else if (c >= 0xff21 && c <= 0xff3a) { // ['A', 'Z'] fullwidth + c += (0x61 - 0xff21); + } else if (c >= 0xff41 && c <= 0xff5a) { // ['a', 'z'] fullwidth + c += (0x61 - 0xff41); + } else if (c === 0x2d || c === 0xff0d) { // '-' or fullwidth dash + c = 0x2d; // '-' + } else { + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); + part = ''; + } + result += char; + continue; + } + part += String.fromCodePoint(c); } - getKanaDiacriticInfo(character) { - const info = DIACRITIC_MAPPING.get(character); - return typeof info !== 'undefined' ? {character: info.character, type: info.type} : null; + if (part.length > 0) { + result += this._convertAlphabeticPartToKana(part, sourceMap, result.length); } + return result; + } - // Furigana distribution + /** + * @returns {boolean} + */ + convertAlphabeticToKanaSupported() { + return this._wanakana !== null; + } - distributeFurigana(term, reading) { - if (reading === term) { - // Same - return [this._createFuriganaSegment(term, '')]; - } + /** + * @param {string} character + * @returns {?{character: string, type: import('japanese-util').DiacriticType}} + */ + getKanaDiacriticInfo(character) { + const info = DIACRITIC_MAPPING.get(character); + return typeof info !== 'undefined' ? {character: info.character, type: info.type} : null; + } - const groups = []; - let groupPre = null; - let isKanaPre = null; - for (const c of term) { - const codePoint = c.codePointAt(0); - const isKana = this.isCodePointKana(codePoint); - if (isKana === isKanaPre) { - groupPre.text += c; - } else { - groupPre = {isKana, text: c, textNormalized: null}; - groups.push(groupPre); - isKanaPre = isKana; - } - } - for (const group of groups) { - if (group.isKana) { - group.textNormalized = this.convertKatakanaToHiragana(group.text); - } + // Furigana distribution + + /** + * @param {string} term + * @param {string} reading + * @returns {import('japanese-util').FuriganaSegment[]} + */ + distributeFurigana(term, reading) { + if (reading === term) { + // Same + return [this._createFuriganaSegment(term, '')]; + } + + /** @type {import('japanese-util').FuriganaGroup[]} */ + const groups = []; + /** @type {?import('japanese-util').FuriganaGroup} */ + let groupPre = null; + let isKanaPre = null; + for (const c of term) { + const codePoint = /** @type {number} */ (c.codePointAt(0)); + const isKana = this.isCodePointKana(codePoint); + if (isKana === isKanaPre) { + /** @type {import('japanese-util').FuriganaGroup} */ (groupPre).text += c; + } else { + groupPre = {isKana, text: c, textNormalized: null}; + groups.push(groupPre); + isKanaPre = isKana; } - - const readingNormalized = this.convertKatakanaToHiragana(reading); - const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0); - if (segments !== null) { - return segments; + } + for (const group of groups) { + if (group.isKana) { + group.textNormalized = this.convertKatakanaToHiragana(group.text); } - - // Fallback - return [this._createFuriganaSegment(term, reading)]; } - distributeFuriganaInflected(term, reading, source) { - const termNormalized = this.convertKatakanaToHiragana(term); - const readingNormalized = this.convertKatakanaToHiragana(reading); - const sourceNormalized = this.convertKatakanaToHiragana(source); - - let mainText = term; - let stemLength = this._getStemLength(termNormalized, sourceNormalized); + const readingNormalized = this.convertKatakanaToHiragana(reading); + const segments = this._segmentizeFurigana(reading, readingNormalized, groups, 0); + if (segments !== null) { + return segments; + } - // Check if source is derived from the reading instead of the term - const readingStemLength = this._getStemLength(readingNormalized, sourceNormalized); - if (readingStemLength > 0 && readingStemLength >= stemLength) { - mainText = reading; - stemLength = readingStemLength; - reading = `${source.substring(0, stemLength)}${reading.substring(stemLength)}`; - } + // Fallback + return [this._createFuriganaSegment(term, reading)]; + } - const segments = []; - if (stemLength > 0) { - mainText = `${source.substring(0, stemLength)}${mainText.substring(stemLength)}`; - const segments2 = this.distributeFurigana(mainText, reading); - let consumed = 0; - for (const segment of segments2) { - const {text} = segment; - const start = consumed; - consumed += text.length; - if (consumed < stemLength) { - segments.push(segment); - } else if (consumed === stemLength) { - segments.push(segment); - break; - } else { - if (start < stemLength) { - segments.push(this._createFuriganaSegment(mainText.substring(start, stemLength), '')); - } - break; + /** + * @param {string} term + * @param {string} reading + * @param {string} source + * @returns {import('japanese-util').FuriganaSegment[]} + */ + distributeFuriganaInflected(term, reading, source) { + const termNormalized = this.convertKatakanaToHiragana(term); + const readingNormalized = this.convertKatakanaToHiragana(reading); + const sourceNormalized = this.convertKatakanaToHiragana(source); + + let mainText = term; + let stemLength = this._getStemLength(termNormalized, sourceNormalized); + + // Check if source is derived from the reading instead of the term + const readingStemLength = this._getStemLength(readingNormalized, sourceNormalized); + if (readingStemLength > 0 && readingStemLength >= stemLength) { + mainText = reading; + stemLength = readingStemLength; + reading = `${source.substring(0, stemLength)}${reading.substring(stemLength)}`; + } + + const segments = []; + if (stemLength > 0) { + mainText = `${source.substring(0, stemLength)}${mainText.substring(stemLength)}`; + const segments2 = this.distributeFurigana(mainText, reading); + let consumed = 0; + for (const segment of segments2) { + const {text} = segment; + const start = consumed; + consumed += text.length; + if (consumed < stemLength) { + segments.push(segment); + } else if (consumed === stemLength) { + segments.push(segment); + break; + } else { + if (start < stemLength) { + segments.push(this._createFuriganaSegment(mainText.substring(start, stemLength), '')); } + break; } } + } - if (stemLength < source.length) { - const remainder = source.substring(stemLength); - const segmentCount = segments.length; - if (segmentCount > 0 && segments[segmentCount - 1].reading.length === 0) { - // Append to the last segment if it has an empty reading - segments[segmentCount - 1].text += remainder; - } else { - // Otherwise, create a new segment - segments.push(this._createFuriganaSegment(remainder, '')); - } + if (stemLength < source.length) { + const remainder = source.substring(stemLength); + const segmentCount = segments.length; + if (segmentCount > 0 && segments[segmentCount - 1].reading.length === 0) { + // Append to the last segment if it has an empty reading + segments[segmentCount - 1].text += remainder; + } else { + // Otherwise, create a new segment + segments.push(this._createFuriganaSegment(remainder, '')); } - - return segments; } - // Miscellaneous - - collapseEmphaticSequences(text, fullCollapse, sourceMap=null) { - let result = ''; - let collapseCodePoint = -1; - const hasSourceMap = (sourceMap !== null); - for (const char of text) { - const c = char.codePointAt(0); - if ( - c === HIRAGANA_SMALL_TSU_CODE_POINT || - c === KATAKANA_SMALL_TSU_CODE_POINT || - c === KANA_PROLONGED_SOUND_MARK_CODE_POINT - ) { - if (collapseCodePoint !== c) { - collapseCodePoint = c; - if (!fullCollapse) { - result += char; - continue; - } + return segments; + } + + // Miscellaneous + + /** + * @param {string} text + * @param {boolean} fullCollapse + * @param {?import('../../general/text-source-map.js').TextSourceMap} [sourceMap] + * @returns {string} + */ + collapseEmphaticSequences(text, fullCollapse, sourceMap=null) { + let result = ''; + let collapseCodePoint = -1; + const hasSourceMap = (sourceMap !== null); + for (const char of text) { + const c = char.codePointAt(0); + if ( + c === HIRAGANA_SMALL_TSU_CODE_POINT || + c === KATAKANA_SMALL_TSU_CODE_POINT || + c === KANA_PROLONGED_SOUND_MARK_CODE_POINT + ) { + if (collapseCodePoint !== c) { + collapseCodePoint = c; + if (!fullCollapse) { + result += char; + continue; } - } else { - collapseCodePoint = -1; - result += char; - continue; } + } else { + collapseCodePoint = -1; + result += char; + continue; + } - if (hasSourceMap) { - sourceMap.combine(Math.max(0, result.length - 1), 1); - } + if (hasSourceMap) { + sourceMap.combine(Math.max(0, result.length - 1), 1); } - return result; } + return result; + } - // Private - - _createFuriganaSegment(text, reading) { - return {text, reading}; - } + // Private - _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) { - const groupCount = groups.length - groupsStart; - if (groupCount <= 0) { - return reading.length === 0 ? [] : null; - } + /** + * @param {string} text + * @param {string} reading + * @returns {import('japanese-util').FuriganaSegment} + */ + _createFuriganaSegment(text, reading) { + return {text, reading}; + } - const group = groups[groupsStart]; - const {isKana, text} = group; - const textLength = text.length; - if (isKana) { - const {textNormalized} = group; - if (readingNormalized.startsWith(textNormalized)) { - const segments = this._segmentizeFurigana( - reading.substring(textLength), - readingNormalized.substring(textLength), - groups, - groupsStart + 1 - ); - if (segments !== null) { - if (reading.startsWith(text)) { - segments.unshift(this._createFuriganaSegment(text, '')); - } else { - segments.unshift(...this._getFuriganaKanaSegments(text, reading)); - } - return segments; + /** + * @param {string} reading + * @param {string} readingNormalized + * @param {import('japanese-util').FuriganaGroup[]} groups + * @param {number} groupsStart + * @returns {?(import('japanese-util').FuriganaSegment[])} + */ + _segmentizeFurigana(reading, readingNormalized, groups, groupsStart) { + const groupCount = groups.length - groupsStart; + if (groupCount <= 0) { + return reading.length === 0 ? [] : null; + } + + const group = groups[groupsStart]; + const {isKana, text} = group; + const textLength = text.length; + if (isKana) { + const {textNormalized} = group; + if (textNormalized !== null && readingNormalized.startsWith(textNormalized)) { + const segments = this._segmentizeFurigana( + reading.substring(textLength), + readingNormalized.substring(textLength), + groups, + groupsStart + 1 + ); + if (segments !== null) { + if (reading.startsWith(text)) { + segments.unshift(this._createFuriganaSegment(text, '')); + } else { + segments.unshift(...this._getFuriganaKanaSegments(text, reading)); } + return segments; } - return null; - } else { - let result = null; - for (let i = reading.length; i >= textLength; --i) { - const segments = this._segmentizeFurigana( - reading.substring(i), - readingNormalized.substring(i), - groups, - groupsStart + 1 - ); - if (segments !== null) { - if (result !== null) { - // More than one way to segmentize the tail; mark as ambiguous - return null; - } - const segmentReading = reading.substring(0, i); - segments.unshift(this._createFuriganaSegment(text, segmentReading)); - result = segments; - } - // There is only one way to segmentize the last non-kana group - if (groupCount === 1) { - break; + } + return null; + } else { + let result = null; + for (let i = reading.length; i >= textLength; --i) { + const segments = this._segmentizeFurigana( + reading.substring(i), + readingNormalized.substring(i), + groups, + groupsStart + 1 + ); + if (segments !== null) { + if (result !== null) { + // More than one way to segmentize the tail; mark as ambiguous + return null; } + const segmentReading = reading.substring(0, i); + segments.unshift(this._createFuriganaSegment(text, segmentReading)); + result = segments; + } + // There is only one way to segmentize the last non-kana group + if (groupCount === 1) { + break; } - return result; - } - } - - _getFuriganaKanaSegments(text, reading) { - const textLength = text.length; - const newSegments = []; - let start = 0; - let state = (reading[0] === text[0]); - for (let i = 1; i < textLength; ++i) { - const newState = (reading[i] === text[i]); - if (state === newState) { continue; } - newSegments.push(this._createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i))); - state = newState; - start = i; } - newSegments.push(this._createFuriganaSegment(text.substring(start, textLength), state ? '' : reading.substring(start, textLength))); - return newSegments; + return result; } + } - _getWanakana() { - const wanakana = this._wanakana; - if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); } - return wanakana; - } + /** + * @param {string} text + * @param {string} reading + * @returns {import('japanese-util').FuriganaSegment[]} + */ + _getFuriganaKanaSegments(text, reading) { + const textLength = text.length; + const newSegments = []; + let start = 0; + let state = (reading[0] === text[0]); + for (let i = 1; i < textLength; ++i) { + const newState = (reading[i] === text[i]); + if (state === newState) { continue; } + newSegments.push(this._createFuriganaSegment(text.substring(start, i), state ? '' : reading.substring(start, i))); + state = newState; + start = i; + } + newSegments.push(this._createFuriganaSegment(text.substring(start, textLength), state ? '' : reading.substring(start, textLength))); + return newSegments; + } - _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { - const wanakana = this._getWanakana(); - const result = wanakana.toHiragana(text); - - // Generate source mapping - if (sourceMap !== null) { - let i = 0; - let resultPos = 0; - const ii = text.length; - while (i < ii) { - // Find smallest matching substring - let iNext = i + 1; - let resultPosNext = result.length; - while (iNext < ii) { - const t = wanakana.toHiragana(text.substring(0, iNext)); - if (t === result.substring(0, t.length)) { - resultPosNext = t.length; - break; - } - ++iNext; - } + /** + * @returns {import('wanakana')} + * @throws {Error} + */ + _getWanakana() { + const wanakana = this._wanakana; + if (wanakana === null) { throw new Error('Functions which use WanaKana are not supported in this context'); } + return wanakana; + } - // Merge characters - const removals = iNext - i - 1; - if (removals > 0) { - sourceMap.combine(sourceMapStart, removals); + /** + * @param {string} text + * @param {?import('../../general/text-source-map.js').TextSourceMap} sourceMap + * @param {number} sourceMapStart + * @returns {string} + */ + _convertAlphabeticPartToKana(text, sourceMap, sourceMapStart) { + const wanakana = this._getWanakana(); + const result = wanakana.toHiragana(text); + + // Generate source mapping + if (sourceMap !== null) { + let i = 0; + let resultPos = 0; + const ii = text.length; + while (i < ii) { + // Find smallest matching substring + let iNext = i + 1; + let resultPosNext = result.length; + while (iNext < ii) { + const t = wanakana.toHiragana(text.substring(0, iNext)); + if (t === result.substring(0, t.length)) { + resultPosNext = t.length; + break; } - ++sourceMapStart; + ++iNext; + } - // Empty elements - const additions = resultPosNext - resultPos - 1; - for (let j = 0; j < additions; ++j) { - sourceMap.insert(sourceMapStart, 0); - ++sourceMapStart; - } + // Merge characters + const removals = iNext - i - 1; + if (removals > 0) { + sourceMap.combine(sourceMapStart, removals); + } + ++sourceMapStart; - i = iNext; - resultPos = resultPosNext; + // Empty elements + const additions = resultPosNext - resultPos - 1; + for (let j = 0; j < additions; ++j) { + sourceMap.insert(sourceMapStart, 0); + ++sourceMapStart; } - } - return result; + i = iNext; + resultPos = resultPosNext; + } } - _getStemLength(text1, text2) { - const minLength = Math.min(text1.length, text2.length); - if (minLength === 0) { return 0; } + return result; + } - let i = 0; - while (true) { - const char1 = text1.codePointAt(i); - const char2 = text2.codePointAt(i); - if (char1 !== char2) { break; } - const charLength = String.fromCodePoint(char1).length; - i += charLength; - if (i >= minLength) { - if (i > minLength) { - i -= charLength; // Don't consume partial UTF16 surrogate characters - } - break; + /** + * @param {string} text1 + * @param {string} text2 + * @returns {number} + */ + _getStemLength(text1, text2) { + const minLength = Math.min(text1.length, text2.length); + if (minLength === 0) { return 0; } + + let i = 0; + while (true) { + const char1 = /** @type {number} */ (text1.codePointAt(i)); + const char2 = /** @type {number} */ (text2.codePointAt(i)); + if (char1 !== char2) { break; } + const charLength = String.fromCodePoint(char1).length; + i += charLength; + if (i >= minLength) { + if (i > minLength) { + i -= charLength; // Don't consume partial UTF16 surrogate characters } + break; } - return i; } + return i; } - - - return JapaneseUtil; -})(); +} diff --git a/ext/js/language/text-scanner.js b/ext/js/language/text-scanner.js index ac7ef3d9..d1b033e6 100644 --- a/ext/js/language/text-scanner.js +++ b/ext/js/language/text-scanner.js @@ -16,11 +16,18 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {EventDispatcher, EventListenerCollection, clone, isObject, log, promiseTimeout} from '../core.js'; +import {EventDispatcher, EventListenerCollection, clone, log} from '../core.js'; import {DocumentUtil} from '../dom/document-util.js'; +import {TextSourceElement} from '../dom/text-source-element.js'; import {yomitan} from '../yomitan.js'; +/** + * @augments EventDispatcher<import('text-scanner').EventType> + */ export class TextScanner extends EventDispatcher { + /** + * @param {import('text-scanner').ConstructorDetails} details + */ constructor({ node, getSearchContext, @@ -32,67 +39,121 @@ export class TextScanner extends EventDispatcher { searchOnClickOnly=false }) { super(); + /** @type {HTMLElement|Window} */ this._node = node; + /** @type {import('text-scanner').GetSearchContextCallback} */ this._getSearchContext = getSearchContext; + /** @type {?(() => Element[])} */ this._ignoreElements = ignoreElements; + /** @type {?((x: number, y: number) => Promise<boolean>)} */ this._ignorePoint = ignorePoint; + /** @type {boolean} */ this._searchTerms = searchTerms; + /** @type {boolean} */ this._searchKanji = searchKanji; + /** @type {boolean} */ this._searchOnClick = searchOnClick; + /** @type {boolean} */ this._searchOnClickOnly = searchOnClickOnly; + /** @type {boolean} */ this._isPrepared = false; + /** @type {?string} */ this._includeSelector = null; + /** @type {?string} */ this._excludeSelector = null; + /** @type {?import('text-scanner').InputInfo} */ this._inputInfoCurrent = null; + /** @type {?Promise<boolean>} */ this._scanTimerPromise = null; + /** @type {?(value: boolean) => void} */ + this._scanTimerPromiseResolve = null; + /** @type {?import('text-source').TextSource} */ this._textSourceCurrent = null; + /** @type {boolean} */ this._textSourceCurrentSelected = false; + /** @type {boolean} */ this._pendingLookup = false; + /** @type {?import('text-scanner').SelectionRestoreInfo} */ this._selectionRestoreInfo = null; + /** @type {boolean} */ this._deepContentScan = false; + /** @type {boolean} */ this._normalizeCssZoom = true; + /** @type {boolean} */ this._selectText = false; + /** @type {number} */ this._delay = 0; + /** @type {boolean} */ this._touchInputEnabled = false; + /** @type {boolean} */ this._pointerEventsEnabled = false; + /** @type {number} */ this._scanLength = 1; + /** @type {boolean} */ this._layoutAwareScan = false; + /** @type {boolean} */ this._preventMiddleMouse = false; + /** @type {boolean} */ this._matchTypePrefix = false; + /** @type {number} */ this._sentenceScanExtent = 0; + /** @type {boolean} */ this._sentenceTerminateAtNewlines = true; + /** @type {Map<string, [includeCharacterAtStart: boolean, includeCharacterAtEnd: boolean]>} */ this._sentenceTerminatorMap = new Map(); + /** @type {Map<string, [character: string, includeCharacterAtStart: boolean]>} */ this._sentenceForwardQuoteMap = new Map(); + /** @type {Map<string, [character: string, includeCharacterAtEnd: boolean]>} */ this._sentenceBackwardQuoteMap = new Map(); + /** @type {import('text-scanner').InputConfig[]} */ this._inputs = []; + /** @type {boolean} */ this._enabled = false; + /** @type {boolean} */ this._enabledValue = false; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {boolean} */ this._preventNextClickScan = false; + /** @type {?import('core').Timeout} */ this._preventNextClickScanTimer = null; + /** @type {number} */ this._preventNextClickScanTimerDuration = 50; + /** @type {() => void} */ this._preventNextClickScanTimerCallback = this._onPreventNextClickScanTimeout.bind(this); + /** @type {?number} */ this._primaryTouchIdentifier = null; + /** @type {boolean} */ this._preventNextContextMenu = false; + /** @type {boolean} */ this._preventNextMouseDown = false; + /** @type {boolean} */ this._preventNextClick = false; + /** @type {boolean} */ this._preventScroll = false; + /** @type {0|1|2|3} */ this._penPointerState = 0; // 0 = not active; 1 = hovering; 2 = touching; 3 = hovering after touching + /** @type {Map<number, string>} */ this._pointerIdTypeMap = new Map(); + /** @type {boolean} */ this._canClearSelection = true; + /** @type {?import('core').Timeout} */ this._textSelectionTimer = null; + /** @type {boolean} */ this._yomitanIsChangingTextSelectionNow = false; + /** @type {boolean} */ this._userHasNotSelectedAnythingManually = true; } + /** @type {boolean} */ get canClearSelection() { return this._canClearSelection; } @@ -101,6 +162,7 @@ export class TextScanner extends EventDispatcher { this._canClearSelection = value; } + /** @type {?string} */ get includeSelector() { return this._includeSelector; } @@ -109,6 +171,7 @@ export class TextScanner extends EventDispatcher { this._includeSelector = value; } + /** @type {?string} */ get excludeSelector() { return this._excludeSelector; } @@ -117,15 +180,22 @@ export class TextScanner extends EventDispatcher { this._excludeSelector = value; } + /** */ prepare() { this._isPrepared = true; this.setEnabled(this._enabled); } + /** + * @returns {boolean} + */ isEnabled() { return this._enabled; } + /** + * @param {boolean} enabled + */ setEnabled(enabled) { this._enabled = enabled; @@ -145,11 +215,13 @@ export class TextScanner extends EventDispatcher { if (value) { this._hookEvents(); - const selection = window.getSelection(); - this._userHasNotSelectedAnythingManually = (selection === null) ? true : selection.isCollapsed; + this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually(); } } + /** + * @param {import('text-scanner').Options} options + */ setOptions({ inputs, deepContentScan, @@ -200,7 +272,7 @@ export class TextScanner extends EventDispatcher { if (typeof sentenceParsingOptions === 'object' && sentenceParsingOptions !== null) { const {scanExtent, terminationCharacterMode, terminationCharacters} = sentenceParsingOptions; if (typeof scanExtent === 'number') { - this._sentenceScanExtent = sentenceParsingOptions.scanExtent; + this._sentenceScanExtent = scanExtent; } if (typeof terminationCharacterMode === 'string') { this._sentenceTerminateAtNewlines = (terminationCharacterMode === 'custom' || terminationCharacterMode === 'newlines'); @@ -229,6 +301,12 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {import('text-source').TextSource} textSource + * @param {number} length + * @param {boolean} layoutAwareScan + * @returns {string} + */ getTextSourceContent(textSource, length, layoutAwareScan) { const clonedTextSource = textSource.clone(); @@ -243,10 +321,14 @@ export class TextScanner extends EventDispatcher { return clonedTextSource.text(); } + /** + * @returns {boolean} + */ hasSelection() { return (this._textSourceCurrent !== null); } + /** */ clearSelection() { if (!this._canClearSelection) { return; } if (this._textSourceCurrent !== null) { @@ -263,15 +345,21 @@ export class TextScanner extends EventDispatcher { } } + /** + * @returns {?import('text-source').TextSource} + */ getCurrentTextSource() { return this._textSourceCurrent; } + /** + * @param {?import('text-source').TextSource} textSource + */ setCurrentTextSource(textSource) { this._textSourceCurrent = textSource; - if (this._selectText && this._userHasNotSelectedAnythingManually) { + if (this._selectText && this._userHasNotSelectedAnythingManually && textSource !== null) { this._yomitanIsChangingTextSelectionNow = true; - this._textSourceCurrent.select(); + textSource.select(); if (this._textSelectionTimer !== null) { clearTimeout(this._textSelectionTimer); } // This timeout uses a 50ms delay to ensure that the selectionchange event has time to occur. // If the delay is 0ms, the timeout will sometimes complete before the event. @@ -285,6 +373,9 @@ export class TextScanner extends EventDispatcher { } } + /** + * @returns {Promise<boolean>} + */ async searchLast() { if (this._textSourceCurrent !== null && this._inputInfoCurrent !== null) { await this._search(this._textSourceCurrent, this._searchTerms, this._searchKanji, this._inputInfoCurrent); @@ -293,6 +384,11 @@ export class TextScanner extends EventDispatcher { return false; } + /** + * @param {import('text-source').TextSource} textSource + * @param {import('text-scanner').InputInfoDetail} [inputDetail] + * @returns {Promise<?import('text-scanner').SearchedEventDetails>} + */ async search(textSource, inputDetail) { const inputInfo = this._createInputInfo(null, 'script', 'script', true, [], [], inputDetail); return await this._search(textSource, this._searchTerms, this._searchKanji, inputInfo); @@ -300,6 +396,11 @@ export class TextScanner extends EventDispatcher { // Private + /** + * @param {import('settings').OptionsContext} baseOptionsContext + * @param {import('text-scanner').InputInfo} inputInfo + * @returns {import('settings').OptionsContext} + */ _createOptionsContextForInput(baseOptionsContext, inputInfo) { const optionsContext = clone(baseOptionsContext); const {modifiers, modifierKeys} = inputInfo; @@ -308,20 +409,33 @@ export class TextScanner extends EventDispatcher { return optionsContext; } + /** + * @param {import('text-source').TextSource} textSource + * @param {boolean} searchTerms + * @param {boolean} searchKanji + * @param {import('text-scanner').InputInfo} inputInfo + * @returns {Promise<?import('text-scanner').SearchedEventDetails>} + */ async _search(textSource, searchTerms, searchKanji, inputInfo) { + /** @type {?import('dictionary').DictionaryEntry[]} */ let dictionaryEntries = null; + /** @type {?import('display').HistoryStateSentence} */ let sentence = null; + /** @type {?import('display').PageType} */ let type = null; + /** @type {?Error} */ let error = null; let searched = false; + /** @type {?import('settings').OptionsContext} */ let optionsContext = null; + /** @type {?import('text-scanner').SearchResultDetail} */ let detail = null; try { const inputInfoDetail = inputInfo.detail; const selectionRestoreInfo = ( - (isObject(inputInfoDetail) && inputInfoDetail.restoreSelection) ? - (this._inputInfoCurrent === null ? this._createSelectionRestoreInfo() : void 0) : + (typeof inputInfoDetail === 'object' && inputInfoDetail !== null && inputInfoDetail.restoreSelection) ? + (this._inputInfoCurrent === null ? this._createSelectionRestoreInfo() : null) : null ); @@ -329,8 +443,11 @@ export class TextScanner extends EventDispatcher { return null; } - ({optionsContext, detail} = await this._getSearchContext()); - optionsContext = this._createOptionsContextForInput(optionsContext, inputInfo); + const getSearchContextPromise = this._getSearchContext(); + const getSearchContextResult = getSearchContextPromise instanceof Promise ? await getSearchContextPromise : getSearchContextPromise; + const {detail: detail2} = getSearchContextResult; + if (typeof detail2 !== 'undefined') { detail = detail2; } + optionsContext = this._createOptionsContextForInput(getSearchContextResult.optionsContext, inputInfo); searched = true; @@ -339,9 +456,9 @@ export class TextScanner extends EventDispatcher { if (result !== null) { ({dictionaryEntries, sentence, type} = result); valid = true; - } else if (textSource !== null && textSource.type === 'element' && await this._hasJapanese(textSource.fullContent)) { + } else if (textSource !== null && textSource instanceof TextSourceElement && await this._hasJapanese(textSource.fullContent)) { dictionaryEntries = []; - sentence = {sentence: '', offset: 0}; + sentence = {text: '', offset: 0}; type = 'terms'; valid = true; } @@ -354,11 +471,12 @@ export class TextScanner extends EventDispatcher { } } } catch (e) { - error = e; + error = e instanceof Error ? e : new Error(`A search error occurred: ${e}`); } if (!searched) { return null; } + /** @type {import('text-scanner').SearchedEventDetails} */ const results = { textScanner: this, type, @@ -374,41 +492,55 @@ export class TextScanner extends EventDispatcher { return results; } + /** */ _resetPreventNextClickScan() { this._preventNextClickScan = false; if (this._preventNextClickScanTimer !== null) { clearTimeout(this._preventNextClickScanTimer); } this._preventNextClickScanTimer = setTimeout(this._preventNextClickScanTimerCallback, this._preventNextClickScanTimerDuration); } + /** */ _onPreventNextClickScanTimeout() { this._preventNextClickScanTimer = null; } + /** */ _onSelectionChange() { if (this._preventNextClickScanTimer !== null) { return; } // Ignore deselection that occurs at the start of the click this._preventNextClickScan = true; } + /** */ _onSelectionChangeCheckUserSelection() { if (this._yomitanIsChangingTextSelectionNow) { return; } - this._userHasNotSelectedAnythingManually = window.getSelection().isCollapsed; + this._userHasNotSelectedAnythingManually = this._computeUserHasNotSelectedAnythingManually(); } + /** + * @param {MouseEvent} e + */ _onSearchClickMouseDown(e) { if (e.button !== 0) { return; } this._resetPreventNextClickScan(); } + /** */ _onSearchClickTouchStart() { this._resetPreventNextClickScan(); } + /** + * @param {MouseEvent} e + */ _onMouseOver(e) { - if (this._ignoreElements !== null && this._ignoreElements().includes(e.target)) { + if (this._ignoreElements !== null && this._ignoreElements().includes(/** @type {Element} */ (e.target))) { this._scanTimerClear(); } } + /** + * @param {MouseEvent} e + */ _onMouseMove(e) { this._scanTimerClear(); @@ -418,6 +550,10 @@ export class TextScanner extends EventDispatcher { this._searchAtFromMouseMove(e.clientX, e.clientY, inputInfo); } + /** + * @param {MouseEvent} e + * @returns {boolean|void} + */ _onMouseDown(e) { if (this._preventNextMouseDown) { this._preventNextMouseDown = false; @@ -443,10 +579,15 @@ export class TextScanner extends EventDispatcher { } } + /** */ _onMouseOut() { this._scanTimerClear(); } + /** + * @param {MouseEvent} e + * @returns {boolean|void} + */ _onClick(e) { if (this._preventNextClick) { this._preventNextClick = false; @@ -460,6 +601,9 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {MouseEvent} e + */ _onSearchClick(e) { const preventNextClickScan = this._preventNextClickScan; this._preventNextClickScan = false; @@ -476,10 +620,15 @@ export class TextScanner extends EventDispatcher { this._searchAt(e.clientX, e.clientY, inputInfo); } + /** */ _onAuxClick() { this._preventNextContextMenu = false; } + /** + * @param {MouseEvent} e + * @returns {boolean|void} + */ _onContextMenu(e) { if (this._preventNextContextMenu) { this._preventNextContextMenu = false; @@ -489,6 +638,9 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {TouchEvent} e + */ _onTouchStart(e) { if (this._primaryTouchIdentifier !== null || e.changedTouches.length === 0) { return; @@ -498,13 +650,20 @@ export class TextScanner extends EventDispatcher { this._onPrimaryTouchStart(e, clientX, clientY, identifier); } + /** + * @param {TouchEvent|PointerEvent} e + * @param {number} x + * @param {number} y + * @param {number} identifier + */ _onPrimaryTouchStart(e, x, y, identifier) { this._preventScroll = false; this._preventNextContextMenu = false; this._preventNextMouseDown = false; this._preventNextClick = false; - if (DocumentUtil.isPointInSelection(x, y, window.getSelection())) { + const selection = window.getSelection(); + if (selection !== null && DocumentUtil.isPointInSelection(x, y, selection)) { return; } @@ -513,11 +672,14 @@ export class TextScanner extends EventDispatcher { if (this._pendingLookup) { return; } const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchStart', e); - if (inputInfo === null || !inputInfo.input.scanOnTouchPress) { return; } + if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchPress)) { return; } this._searchAtFromTouchStart(x, y, inputInfo); } + /** + * @param {TouchEvent} e + */ _onTouchEnd(e) { if (this._primaryTouchIdentifier === null) { return; } @@ -528,6 +690,12 @@ export class TextScanner extends EventDispatcher { this._onPrimaryTouchEnd(e, clientX, clientY, true); } + /** + * @param {TouchEvent|PointerEvent} e + * @param {number} x + * @param {number} y + * @param {boolean} allowSearch + */ _onPrimaryTouchEnd(e, x, y, allowSearch) { this._primaryTouchIdentifier = null; this._preventScroll = false; @@ -538,11 +706,14 @@ export class TextScanner extends EventDispatcher { if (!allowSearch) { return; } const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchEnd', e); - if (inputInfo === null || !inputInfo.input.scanOnTouchRelease) { return; } + if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchRelease)) { return; } this._searchAtFromTouchEnd(x, y, inputInfo); } + /** + * @param {TouchEvent} e + */ _onTouchCancel(e) { if (this._primaryTouchIdentifier === null) { return; } @@ -552,6 +723,9 @@ export class TextScanner extends EventDispatcher { this._onPrimaryTouchEnd(e, 0, 0, false); } + /** + * @param {TouchEvent} e + */ _onTouchMove(e) { if (this._primaryTouchIdentifier === null) { return; } @@ -568,13 +742,18 @@ export class TextScanner extends EventDispatcher { const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); if (inputInfo === null) { return; } - if (inputInfo.input.scanOnTouchMove) { + const {input} = inputInfo; + if (input !== null && input.scanOnTouchMove) { this._searchAt(primaryTouch.clientX, primaryTouch.clientY, inputInfo); } e.preventDefault(); // Disable scroll } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerOver(e) { const {pointerType, pointerId, isPrimary} = e; if (pointerType === 'pen') { @@ -584,11 +763,15 @@ export class TextScanner extends EventDispatcher { if (!isPrimary) { return; } switch (pointerType) { case 'mouse': return this._onMousePointerOver(e); - case 'touch': return this._onTouchPointerOver(e); + case 'touch': return this._onTouchPointerOver(); case 'pen': return this._onPenPointerOver(e); } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerDown(e) { if (!e.isPrimary) { return; } switch (this._getPointerEventType(e)) { @@ -598,6 +781,10 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerMove(e) { if (!e.isPrimary) { return; } switch (this._getPointerEventType(e)) { @@ -607,92 +794,144 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerUp(e) { if (!e.isPrimary) { return; } switch (this._getPointerEventType(e)) { - case 'mouse': return this._onMousePointerUp(e); + case 'mouse': return this._onMousePointerUp(); case 'touch': return this._onTouchPointerUp(e); case 'pen': return this._onPenPointerUp(e); } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerCancel(e) { this._pointerIdTypeMap.delete(e.pointerId); if (!e.isPrimary) { return; } switch (e.pointerType) { - case 'mouse': return this._onMousePointerCancel(e); + case 'mouse': return this._onMousePointerCancel(); case 'touch': return this._onTouchPointerCancel(e); - case 'pen': return this._onPenPointerCancel(e); + case 'pen': return this._onPenPointerCancel(); } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onPointerOut(e) { this._pointerIdTypeMap.delete(e.pointerId); if (!e.isPrimary) { return; } switch (e.pointerType) { - case 'mouse': return this._onMousePointerOut(e); - case 'touch': return this._onTouchPointerOut(e); - case 'pen': return this._onPenPointerOut(e); + case 'mouse': return this._onMousePointerOut(); + case 'touch': return this._onTouchPointerOut(); + case 'pen': return this._onPenPointerOut(); } } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onMousePointerOver(e) { return this._onMouseOver(e); } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onMousePointerDown(e) { return this._onMouseDown(e); } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onMousePointerMove(e) { return this._onMouseMove(e); } + /** */ _onMousePointerUp() { // NOP } - _onMousePointerCancel(e) { - return this._onMouseOut(e); + /** + * @returns {boolean|void} + */ + _onMousePointerCancel() { + return this._onMouseOut(); } - _onMousePointerOut(e) { - return this._onMouseOut(e); + /** + * @returns {boolean|void} + */ + _onMousePointerOut() { + return this._onMouseOut(); } + /** */ _onTouchPointerOver() { // NOP } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onTouchPointerDown(e) { const {clientX, clientY, pointerId} = e; this._onPrimaryTouchStart(e, clientX, clientY, pointerId); } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onTouchPointerMove(e) { if (!this._preventScroll || !e.cancelable) { return; } const inputInfo = this._getMatchingInputGroupFromEvent('touch', 'touchMove', e); - if (inputInfo === null || !inputInfo.input.scanOnTouchMove) { return; } + if (inputInfo === null || !(inputInfo.input !== null && inputInfo.input.scanOnTouchMove)) { return; } this._searchAt(e.clientX, e.clientY, inputInfo); } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onTouchPointerUp(e) { const {clientX, clientY} = e; return this._onPrimaryTouchEnd(e, clientX, clientY, true); } + /** + * @param {PointerEvent} e + * @returns {boolean|void} + */ _onTouchPointerCancel(e) { return this._onPrimaryTouchEnd(e, 0, 0, false); } + /** */ _onTouchPointerOut() { // NOP } + /** + * @param {PointerEvent} e + */ _onTouchMovePreventScroll(e) { if (!this._preventScroll) { return; } @@ -703,31 +942,45 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {PointerEvent} e + */ _onPenPointerOver(e) { this._penPointerState = 1; this._searchAtFromPen(e, 'pointerOver', false); } + /** + * @param {PointerEvent} e + */ _onPenPointerDown(e) { this._penPointerState = 2; this._searchAtFromPen(e, 'pointerDown', true); } + /** + * @param {PointerEvent} e + */ _onPenPointerMove(e) { if (this._penPointerState === 2 && (!this._preventScroll || !e.cancelable)) { return; } this._searchAtFromPen(e, 'pointerMove', true); } + /** + * @param {PointerEvent} e + */ _onPenPointerUp(e) { this._penPointerState = 3; this._preventScroll = false; this._searchAtFromPen(e, 'pointerUp', false); } - _onPenPointerCancel(e) { - this._onPenPointerOut(e); + /** */ + _onPenPointerCancel() { + this._onPenPointerOut(); } + /** */ _onPenPointerOut() { this._penPointerState = 0; this._preventScroll = false; @@ -736,32 +989,54 @@ export class TextScanner extends EventDispatcher { this._preventNextClick = false; } + /** + * @returns {Promise<boolean>} + */ async _scanTimerWait() { const delay = this._delay; - const promise = promiseTimeout(delay, true); + const promise = /** @type {Promise<boolean>} */ (new Promise((resolve) => { + /** @type {?import('core').Timeout} */ + let timeout = setTimeout(() => { + timeout = null; + resolve(true); + }, delay); + this._scanTimerPromiseResolve = (value) => { + if (timeout === null) { return; } + clearTimeout(timeout); + timeout = null; + resolve(value); + }; + })); this._scanTimerPromise = promise; try { return await promise; } finally { if (this._scanTimerPromise === promise) { this._scanTimerPromise = null; + this._scanTimerPromiseResolve = null; } } } + /** */ _scanTimerClear() { - if (this._scanTimerPromise !== null) { - this._scanTimerPromise.resolve(false); - this._scanTimerPromise = null; - } + if (this._scanTimerPromiseResolve === null) { return; } + this._scanTimerPromiseResolve(false); + this._scanTimerPromiseResolve = null; + this._scanTimerPromise = null; } + /** + * @returns {boolean} + */ _arePointerEventsSupported() { return (this._pointerEventsEnabled && typeof PointerEvent !== 'undefined'); } + /** */ _hookEvents() { const capture = true; + /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ let eventListenerInfos; if (this._searchOnClickOnly) { eventListenerInfos = this._getMouseClickOnlyEventListeners(capture); @@ -779,11 +1054,15 @@ export class TextScanner extends EventDispatcher { eventListenerInfos.push(this._getSelectionChangeCheckUserSelectionListener()); - for (const args of eventListenerInfos) { + for (const [...args] of eventListenerInfos) { this._eventListeners.addEventListener(...args); } } + /** + * @param {boolean} capture + * @returns {import('event-listener-collection').AddEventListenerArgs[]} + */ _getPointerEventListeners(capture) { return [ [this._node, 'pointerover', this._onPointerOver.bind(this), capture], @@ -799,6 +1078,10 @@ export class TextScanner extends EventDispatcher { ]; } + /** + * @param {boolean} capture + * @returns {import('event-listener-collection').AddEventListenerArgs[]} + */ _getMouseEventListeners(capture) { return [ [this._node, 'mousedown', this._onMouseDown.bind(this), capture], @@ -809,6 +1092,10 @@ export class TextScanner extends EventDispatcher { ]; } + /** + * @param {boolean} capture + * @returns {import('event-listener-collection').AddEventListenerArgs[]} + */ _getTouchEventListeners(capture) { return [ [this._node, 'auxclick', this._onAuxClick.bind(this), capture], @@ -820,14 +1107,23 @@ export class TextScanner extends EventDispatcher { ]; } + /** + * @param {boolean} capture + * @returns {import('event-listener-collection').AddEventListenerArgs[]} + */ _getMouseClickOnlyEventListeners(capture) { return [ [this._node, 'click', this._onClick.bind(this), capture] ]; } + /** + * @param {boolean} capture + * @returns {import('event-listener-collection').AddEventListenerArgs[]} + */ _getMouseClickOnlyEventListeners2(capture) { const {documentElement} = document; + /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ const entries = [ [document, 'selectionchange', this._onSelectionChange.bind(this)] ]; @@ -840,10 +1136,18 @@ export class TextScanner extends EventDispatcher { return entries; } + /** + * @returns {import('event-listener-collection').AddEventListenerArgs} + */ _getSelectionChangeCheckUserSelectionListener() { return [document, 'selectionchange', this._onSelectionChangeCheckUserSelection.bind(this)]; } + /** + * @param {TouchList} touchList + * @param {number} identifier + * @returns {?Touch} + */ _getTouch(touchList, identifier) { for (const touch of touchList) { if (touch.identifier === identifier) { @@ -853,6 +1157,13 @@ export class TextScanner extends EventDispatcher { return null; } + /** + * @param {import('text-source').TextSource} textSource + * @param {boolean} searchTerms + * @param {boolean} searchKanji + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<?import('text-scanner').SearchResults>} + */ async _findDictionaryEntries(textSource, searchTerms, searchKanji, optionsContext) { if (textSource === null) { return null; @@ -868,6 +1179,11 @@ export class TextScanner extends EventDispatcher { return null; } + /** + * @param {import('text-source').TextSource} textSource + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<?import('text-scanner').TermSearchResults>} + */ async _findTermDictionaryEntries(textSource, optionsContext) { const scanLength = this._scanLength; const sentenceScanExtent = this._sentenceScanExtent; @@ -879,6 +1195,7 @@ export class TextScanner extends EventDispatcher { const searchText = this.getTextSourceContent(textSource, scanLength, layoutAwareScan); if (searchText.length === 0) { return null; } + /** @type {import('api').FindTermsDetails} */ const details = {}; if (this._matchTypePrefix) { details.matchType = 'prefix'; } const {dictionaryEntries, originalTextLength} = await yomitan.api.termsFind(searchText, details, optionsContext); @@ -898,6 +1215,11 @@ export class TextScanner extends EventDispatcher { return {dictionaryEntries, sentence, type: 'terms'}; } + /** + * @param {import('text-source').TextSource} textSource + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<?import('text-scanner').KanjiSearchResults>} + */ async _findKanjiDictionaryEntries(textSource, optionsContext) { const sentenceScanExtent = this._sentenceScanExtent; const sentenceTerminateAtNewlines = this._sentenceTerminateAtNewlines; @@ -925,6 +1247,11 @@ export class TextScanner extends EventDispatcher { return {dictionaryEntries, sentence, type: 'kanji'}; } + /** + * @param {number} x + * @param {number} y + * @param {import('text-scanner').InputInfo} inputInfo + */ async _searchAt(x, y, inputInfo) { if (this._pendingLookup) { return; } @@ -948,11 +1275,13 @@ export class TextScanner extends EventDispatcher { deepContentScan: this._deepContentScan, normalizeCssZoom: this._normalizeCssZoom }); - try { - await this._search(textSource, searchTerms, searchKanji, inputInfo); - } finally { - if (textSource !== null) { - textSource.cleanup(); + if (textSource !== null) { + try { + await this._search(textSource, searchTerms, searchKanji, inputInfo); + } finally { + if (textSource !== null) { + textSource.cleanup(); + } } } } catch (e) { @@ -962,6 +1291,11 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {number} x + * @param {number} y + * @param {import('text-scanner').InputInfo} inputInfo + */ async _searchAtFromMouseMove(x, y, inputInfo) { if (this._pendingLookup) { return; } @@ -975,15 +1309,21 @@ export class TextScanner extends EventDispatcher { await this._searchAt(x, y, inputInfo); } + /** + * @param {number} x + * @param {number} y + * @param {import('text-scanner').InputInfo} inputInfo + */ async _searchAtFromTouchStart(x, y, inputInfo) { const textSourceCurrentPrevious = this._textSourceCurrent !== null ? this._textSourceCurrent.clone() : null; - const preventScroll = inputInfo.input.preventTouchScrolling; + const {input} = inputInfo; + const preventScroll = input !== null && input.preventTouchScrolling; await this._searchAt(x, y, inputInfo); if ( this._textSourceCurrent !== null && - !this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious) + !(textSourceCurrentPrevious !== null && this._textSourceCurrent.hasSameStart(textSourceCurrentPrevious)) ) { this._preventScroll = preventScroll; this._preventNextContextMenu = true; @@ -991,10 +1331,20 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {number} x + * @param {number} y + * @param {import('text-scanner').InputInfo} inputInfo + */ async _searchAtFromTouchEnd(x, y, inputInfo) { await this._searchAt(x, y, inputInfo); } + /** + * @param {PointerEvent} e + * @param {import('text-scanner').PointerEventType} eventType + * @param {boolean} prevent + */ async _searchAtFromPen(e, eventType, prevent) { if (this._pendingLookup) { return; } @@ -1002,9 +1352,9 @@ export class TextScanner extends EventDispatcher { if (inputInfo === null) { return; } const {input} = inputInfo; - if (!this._isPenEventSupported(eventType, input)) { return; } + if (input === null || !this._isPenEventSupported(eventType, input)) { return; } - const preventScroll = input.preventPenScrolling; + const preventScroll = input !== null && input.preventPenScrolling; await this._searchAt(e.clientX, e.clientY, inputInfo); @@ -1019,6 +1369,11 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {import('text-scanner').PointerEventType} eventType + * @param {import('text-scanner').InputConfig} input + * @returns {boolean} + */ _isPenEventSupported(eventType, input) { switch (eventType) { case 'pointerDown': @@ -1038,12 +1393,25 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {import('text-scanner').PointerType} pointerType + * @param {import('text-scanner').PointerEventType} eventType + * @param {MouseEvent|TouchEvent} event + * @returns {?import('text-scanner').InputInfo} + */ _getMatchingInputGroupFromEvent(pointerType, eventType, event) { const modifiers = DocumentUtil.getActiveModifiersAndButtons(event); const modifierKeys = DocumentUtil.getActiveModifiers(event); return this._getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys); } + /** + * @param {import('text-scanner').PointerType} pointerType + * @param {import('text-scanner').PointerEventType} eventType + * @param {import('input').Modifier[]} modifiers + * @param {import('input').ModifierKey[]} modifierKeys + * @returns {?import('text-scanner').InputInfo} + */ _getMatchingInputGroup(pointerType, eventType, modifiers, modifierKeys) { let fallbackIndex = -1; const modifiersSet = new Set(modifiers); @@ -1067,10 +1435,25 @@ export class TextScanner extends EventDispatcher { ); } + /** + * @param {?import('text-scanner').InputConfig} input + * @param {import('text-scanner').PointerType} pointerType + * @param {import('text-scanner').PointerEventType} eventType + * @param {boolean} passive + * @param {import('input').Modifier[]} modifiers + * @param {import('input').ModifierKey[]} modifierKeys + * @param {import('text-scanner').InputInfoDetail} [detail] + * @returns {import('text-scanner').InputInfo} + */ _createInputInfo(input, pointerType, eventType, passive, modifiers, modifierKeys, detail) { return {input, pointerType, eventType, passive, modifiers, modifierKeys, detail}; } + /** + * @param {Set<string>} set + * @param {string[]} values + * @returns {boolean} + */ _setHasAll(set, values) { for (const value of values) { if (!set.has(value)) { @@ -1080,6 +1463,10 @@ export class TextScanner extends EventDispatcher { return true; } + /** + * @param {import('text-scanner').InputOptionsOuter} input + * @returns {import('text-scanner').InputConfig} + */ _convertInput(input) { const {options} = input; return { @@ -1101,6 +1488,10 @@ export class TextScanner extends EventDispatcher { }; } + /** + * @param {string} value + * @returns {string[]} + */ _getInputArray(value) { return ( typeof value === 'string' ? @@ -1109,6 +1500,10 @@ export class TextScanner extends EventDispatcher { ); } + /** + * @param {{mouse: boolean, touch: boolean, pen: boolean}} details + * @returns {Set<'mouse'|'touch'|'pen'>} + */ _getInputTypeSet({mouse, touch, pen}) { const set = new Set(); if (mouse) { set.add('mouse'); } @@ -1117,16 +1512,30 @@ export class TextScanner extends EventDispatcher { return set; } + /** + * @param {unknown} value + * @returns {boolean} + */ _getInputBoolean(value) { return typeof value === 'boolean' && value; } + /** + * @param {PointerEvent} e + * @returns {string} + */ _getPointerEventType(e) { // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events. const cachedPointerType = this._pointerIdTypeMap.get(e.pointerId); return (typeof cachedPointerType !== 'undefined' ? cachedPointerType : e.pointerType); } + /** + * @param {import('text-source').TextSource} textSource + * @param {?string} includeSelector + * @param {?string} excludeSelector + * @param {boolean} layoutAwareScan + */ _constrainTextSource(textSource, includeSelector, excludeSelector, layoutAwareScan) { let length = textSource.text().length; while (length > 0) { @@ -1143,6 +1552,10 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {string} text + * @returns {Promise<boolean>} + */ async _hasJapanese(text) { try { return await yomitan.api.textHasJapaneseCharacters(text); @@ -1151,19 +1564,28 @@ export class TextScanner extends EventDispatcher { } } + /** + * @returns {import('text-scanner').SelectionRestoreInfo} + */ _createSelectionRestoreInfo() { const ranges = []; const selection = window.getSelection(); - for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { - const range = selection.getRangeAt(i); - ranges.push(range.cloneRange()); + if (selection !== null) { + for (let i = 0, ii = selection.rangeCount; i < ii; ++i) { + const range = selection.getRangeAt(i); + ranges.push(range.cloneRange()); + } } return {ranges}; } + /** + * @param {import('text-scanner').SelectionRestoreInfo} selectionRestoreInfo + */ _restoreSelection(selectionRestoreInfo) { const {ranges} = selectionRestoreInfo; const selection = window.getSelection(); + if (selection === null) { return; } selection.removeAllRanges(); for (const range of ranges) { try { @@ -1174,7 +1596,18 @@ export class TextScanner extends EventDispatcher { } } + /** + * @param {string} reason + */ _triggerClear(reason) { this.trigger('clear', {reason}); } + + /** + * @returns {boolean} + */ + _computeUserHasNotSelectedAnythingManually() { + const selection = window.getSelection(); + return selection === null || selection.isCollapsed; + } } diff --git a/ext/js/language/translator.js b/ext/js/language/translator.js index 4044f379..aa1b71dd 100644 --- a/ext/js/language/translator.js +++ b/ext/js/language/translator.js @@ -19,41 +19,34 @@ import {RegexUtil} from '../general/regex-util.js'; import {TextSourceMap} from '../general/text-source-map.js'; import {Deinflector} from './deinflector.js'; -import {DictionaryDatabase} from './dictionary-database.js'; /** * Class which finds term and kanji dictionary entries for text. */ export class Translator { /** - * Information about how popup content should be shown, specifically related to the outer popup frame. - * @typedef {object} TermFrequency - * @property {string} term The term. - * @property {string} reading The reading of the term. - * @property {string} dictionary The name of the dictionary that the term frequency originates from. - * @property {boolean} hasReading Whether or not a reading was specified. - * @property {number|string} frequency The frequency value for the term. - */ - - /** * Creates a new Translator instance. - * @param {object} details The details for the class. - * @param {JapaneseUtil} details.japaneseUtil An instance of JapaneseUtil. - * @param {DictionaryDatabase} details.database An instance of DictionaryDatabase. + * @param {import('translator').ConstructorDetails} details The details for the class. */ constructor({japaneseUtil, database}) { + /** @type {import('./sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {import('./dictionary-database.js').DictionaryDatabase} */ this._database = database; + /** @type {?Deinflector} */ this._deinflector = null; + /** @type {import('translator').DictionaryTagCache} */ this._tagCache = new Map(); + /** @type {Intl.Collator} */ this._stringComparer = new Intl.Collator('en-US'); // Invariant locale + /** @type {RegExp} */ this._numberRegex = /[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?/; } /** * Initializes the instance for use. The public API should not be used until * this function has been called. - * @param {object} deinflectionReasons The raw deinflections reasons data that the Deinflector uses. + * @param {import('deinflector').ReasonsRaw} deinflectionReasons The raw deinflections reasons data that the Deinflector uses. */ prepare(deinflectionReasons) { this._deinflector = new Deinflector(deinflectionReasons); @@ -68,22 +61,23 @@ export class Translator { /** * Finds term definitions for the given text. - * @param {string} mode The mode to use for finding terms, which determines the format of the resulting array. + * @param {import('translator').FindTermsMode} mode The mode to use for finding terms, which determines the format of the resulting array. * One of: 'group', 'merge', 'split', 'simple' * @param {string} text The text to find terms for. - * @param {Translation.FindTermsOptions} options A object describing settings about the lookup. - * @returns {{dictionaryEntries: Translation.TermDictionaryEntry[], originalTextLength: number}} An object containing dictionary entries and the length of the original source text. + * @param {import('translation').FindTermsOptions} options A object describing settings about the lookup. + * @returns {Promise<{dictionaryEntries: import('dictionary').TermDictionaryEntry[], originalTextLength: number}>} An object containing dictionary entries and the length of the original source text. */ async findTerms(mode, text, options) { const {enabledDictionaryMap, excludeDictionaryDefinitions, sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options; - let {dictionaryEntries, originalTextLength} = await this._findTermsInternal(text, enabledDictionaryMap, options); + const tagAggregator = new TranslatorTagAggregator(); + let {dictionaryEntries, originalTextLength} = await this._findTermsInternal(text, enabledDictionaryMap, options, tagAggregator); switch (mode) { case 'group': - dictionaryEntries = this._groupDictionaryEntriesByHeadword(dictionaryEntries); + dictionaryEntries = this._groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator); break; case 'merge': - dictionaryEntries = await this._getRelatedDictionaryEntries(dictionaryEntries, options.mainDictionary, enabledDictionaryMap); + dictionaryEntries = await this._getRelatedDictionaryEntries(dictionaryEntries, options.mainDictionary, enabledDictionaryMap, tagAggregator); break; } @@ -91,17 +85,19 @@ export class Translator { this._removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions); } - if (mode === 'simple') { + if (mode !== 'simple') { + await this._addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator); + await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets()); + } else { if (sortFrequencyDictionary !== null) { - const sortDictionaryMap = [sortFrequencyDictionary] - .filter((key) => enabledDictionaryMap.has(key)) - .reduce((subMap, key) => subMap.set(key, enabledDictionaryMap.get(key)), new Map()); - await this._addTermMeta(dictionaryEntries, sortDictionaryMap); + /** @type {import('translation').TermEnabledDictionaryMap} */ + const sortDictionaryMap = new Map(); + const value = enabledDictionaryMap.get(sortFrequencyDictionary); + if (typeof value !== 'undefined') { + sortDictionaryMap.set(sortFrequencyDictionary, value); + } + await this._addTermMeta(dictionaryEntries, sortDictionaryMap, tagAggregator); } - this._clearTermTags(dictionaryEntries); - } else { - await this._addTermMeta(dictionaryEntries, enabledDictionaryMap); - await this._expandTermTags(dictionaryEntries); } if (sortFrequencyDictionary !== null) { @@ -125,8 +121,8 @@ export class Translator { * @param {string} text The text to find kanji definitions for. This string can be of any length, * but is typically just one character, which is a single kanji. If the string is multiple * characters long, each character will be searched in the database. - * @param {Translation.FindKanjiOptions} options A object describing settings about the lookup. - * @returns {Translation.KanjiDictionaryEntry[]} An array of definitions. See the _createKanjiDefinition() function for structure details. + * @param {import('translation').FindKanjiOptions} options A object describing settings about the lookup. + * @returns {Promise<import('dictionary').KanjiDictionaryEntry[]>} An array of definitions. See the _createKanjiDefinition() function for structure details. */ async findKanji(text, options) { const {enabledDictionaryMap} = options; @@ -140,19 +136,18 @@ export class Translator { this._sortDatabaseEntriesByIndex(databaseEntries); + /** @type {import('dictionary').KanjiDictionaryEntry[]} */ const dictionaryEntries = []; + const tagAggregator = new TranslatorTagAggregator(); for (const {character, onyomi, kunyomi, tags, definitions, stats, dictionary} of databaseEntries) { const expandedStats = await this._expandKanjiStats(stats, dictionary); - - const tagGroups = []; - if (tags.length > 0) { tagGroups.push(this._createTagGroup(dictionary, tags)); } - - const dictionaryEntry = this._createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, tagGroups, expandedStats, definitions); + const dictionaryEntry = this._createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, expandedStats, definitions); dictionaryEntries.push(dictionaryEntry); + tagAggregator.addTags(dictionaryEntry.tags, dictionary, tags); } await this._addKanjiMeta(dictionaryEntries, enabledDictionaryMap); - await this._expandKanjiTags(dictionaryEntries); + await this._expandTagGroupsAndGroup(tagAggregator.getTagExpansionTargets()); this._sortKanjiDictionaryEntryData(dictionaryEntries); @@ -162,10 +157,10 @@ export class Translator { /** * Gets a list of frequency information for a given list of term-reading pairs * and a list of dictionaries. - * @param {{term: string, reading: string|null}[]} termReadingList An array of `{term, reading}` pairs. If reading is null, + * @param {import('translator').TermReadingList} termReadingList An array of `{term, reading}` pairs. If reading is null, * the reading won't be compared. - * @param {Iterable<string>} dictionaries An array of dictionary names. - * @returns {TermFrequency[]} An array of term frequencies. + * @param {string[]} dictionaries An array of dictionary names. + * @returns {Promise<import('translator').TermFrequencySimple[]>} An array of term frequencies. */ async getTermFrequencies(termReadingList, dictionaries) { const dictionarySet = new Set(); @@ -176,25 +171,26 @@ export class Translator { const termList = termReadingList.map(({term}) => term); const metas = await this._database.findTermMetaBulk(termList, dictionarySet); + /** @type {import('translator').TermFrequencySimple[]} */ const results = []; for (const {mode, data, dictionary, index} of metas) { if (mode !== 'freq') { continue; } let {term, reading} = termReadingList[index]; - let frequency = data; - const hasReading = (data !== null && typeof data === 'object'); - if (hasReading) { - if (data.reading !== reading) { - if (reading !== null) { continue; } - reading = data.reading; - } - frequency = data.frequency; + const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string'); + if (hasReading && data.reading !== reading) { + if (reading !== null) { continue; } + reading = data.reading; } + const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data); + const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency); results.push({ term, reading, dictionary, hasReading, - frequency + frequency: frequencyValue, + displayValue, + displayValueParsed }); } return results; @@ -202,7 +198,14 @@ export class Translator { // Find terms internal implementation - async _findTermsInternal(text, enabledDictionaryMap, options) { + /** + * @param {string} text + * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap + * @param {import('translation').FindTermsOptions} options + * @param {TranslatorTagAggregator} tagAggregator + * @returns {Promise<import('translator').FindTermsResult>} + */ + async _findTermsInternal(text, enabledDictionaryMap, options, tagAggregator) { if (options.removeNonJapaneseCharacters) { text = this._getJapaneseOnlyText(text); } @@ -221,7 +224,7 @@ export class Translator { for (const databaseEntry of databaseEntries) { const {id} = databaseEntry; if (ids.has(id)) { continue; } - const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, true, enabledDictionaryMap); + const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, true, enabledDictionaryMap, tagAggregator); dictionaryEntries.push(dictionaryEntry); ids.add(id); } @@ -230,11 +233,17 @@ export class Translator { return {dictionaryEntries, originalTextLength}; } + /** + * @param {string} text + * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap + * @param {import('translation').FindTermsOptions} options + * @returns {Promise<import('translation-internal').DatabaseDeinflection[]>} + */ async _findTermsInternal2(text, enabledDictionaryMap, options) { const deinflections = ( options.deinflect ? this._getAllDeinflections(text, options) : - [this._createDeinflection(text, text, text, 0, [], [])] + [this._createDeinflection(text, text, text, 0, [])] ); if (deinflections.length === 0) { return []; } @@ -271,7 +280,13 @@ export class Translator { // Deinflections and text transformations + /** + * @param {string} text + * @param {import('translation').FindTermsOptions} options + * @returns {import('translation-internal').DatabaseDeinflection[]} + */ _getAllDeinflections(text, options) { + /** @type {import('translation-internal').TextDeinflectionOptionsArrays} */ const textOptionVariantArray = [ this._getTextReplacementsVariants(options), this._getTextOptionEntryVariants(options.convertHalfWidthCharacters), @@ -283,9 +298,10 @@ export class Translator { ]; const jp = this._japaneseUtil; + /** @type {import('translation-internal').DatabaseDeinflection[]} */ const deinflections = []; const used = new Set(); - for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of this._getArrayVariants(textOptionVariantArray)) { + for (const [textReplacements, halfWidth, numeric, alphabetic, katakana, hiragana, [collapseEmphatic, collapseEmphaticFull]] of /** @type {Generator<import('translation-internal').TextDeinflectionOptions, void, unknown>} */ (this._getArrayVariants(textOptionVariantArray))) { let text2 = text; const sourceMap = new TextSourceMap(text2); if (textReplacements !== null) { @@ -315,14 +331,20 @@ export class Translator { if (used.has(source)) { break; } used.add(source); const rawSource = sourceMap.source.substring(0, sourceMap.getSourceLength(i)); - for (const {term, rules, reasons} of this._deinflector.deinflect(source)) { - deinflections.push(this._createDeinflection(rawSource, source, term, rules, reasons, [])); + for (const {term, rules, reasons} of /** @type {Deinflector} */ (this._deinflector).deinflect(source)) { + deinflections.push(this._createDeinflection(rawSource, source, term, rules, reasons)); } } } return deinflections; } + /** + * @param {string} text + * @param {TextSourceMap} sourceMap + * @param {import('translation').FindTermsTextReplacement[]} replacements + * @returns {string} + */ _applyTextReplacements(text, sourceMap, replacements) { for (const {pattern, replacement} of replacements) { text = RegexUtil.applyTextReplacement(text, sourceMap, pattern, replacement); @@ -330,11 +352,15 @@ export class Translator { return text; } + /** + * @param {string} text + * @returns {string} + */ _getJapaneseOnlyText(text) { const jp = this._japaneseUtil; let length = 0; for (const c of text) { - if (!jp.isCodePointJapanese(c.codePointAt(0))) { + if (!jp.isCodePointJapanese(/** @type {number} */ (c.codePointAt(0)))) { return text.substring(0, length); } length += c.length; @@ -342,6 +368,10 @@ export class Translator { return text; } + /** + * @param {import('translation').FindTermsVariantMode} value + * @returns {boolean[]} + */ _getTextOptionEntryVariants(value) { switch (value) { case 'true': return [true]; @@ -350,7 +380,12 @@ export class Translator { } } + /** + * @param {import('translation').FindTermsOptions} options + * @returns {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]} + */ _getCollapseEmphaticOptions(options) { + /** @type {[collapseEmphatic: boolean, collapseEmphaticFull: boolean][]} */ const collapseEmphaticOptions = [[false, false]]; switch (options.collapseEmphaticSequences) { case 'true': @@ -363,20 +398,43 @@ export class Translator { return collapseEmphaticOptions; } + /** + * @param {import('translation').FindTermsOptions} options + * @returns {(import('translation').FindTermsTextReplacement[] | null)[]} + */ _getTextReplacementsVariants(options) { return options.textReplacements; } - _createDeinflection(originalText, transformedText, deinflectedText, rules, reasons, databaseEntries) { - return {originalText, transformedText, deinflectedText, rules, reasons, databaseEntries}; + /** + * @param {string} originalText + * @param {string} transformedText + * @param {string} deinflectedText + * @param {import('translation-internal').DeinflectionRuleFlags} rules + * @param {string[]} reasons + * @returns {import('translation-internal').DatabaseDeinflection} + */ + _createDeinflection(originalText, transformedText, deinflectedText, rules, reasons) { + return {originalText, transformedText, deinflectedText, rules, reasons, databaseEntries: []}; } // Term dictionary entry grouping - async _getRelatedDictionaryEntries(dictionaryEntries, mainDictionary, enabledDictionaryMap) { + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + * @param {string} mainDictionary + * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap + * @param {TranslatorTagAggregator} tagAggregator + * @returns {Promise<import('dictionary').TermDictionaryEntry[]>} + */ + async _getRelatedDictionaryEntries(dictionaryEntries, mainDictionary, enabledDictionaryMap, tagAggregator) { + /** @type {import('translator').SequenceQuery[]} */ const sequenceList = []; + /** @type {import('translator').DictionaryEntryGroup[]} */ const groupedDictionaryEntries = []; + /** @type {Map<number, import('translator').DictionaryEntryGroup>} */ const groupedDictionaryEntriesMap = new Map(); + /** @type {Map<number, import('dictionary').TermDictionaryEntry>} */ const ungroupedDictionaryEntriesMap = new Map(); for (const dictionaryEntry of dictionaryEntries) { const {definitions: [{id, dictionary, sequences: [sequence]}]} = dictionaryEntry; @@ -400,24 +458,31 @@ export class Translator { if (sequenceList.length > 0) { const secondarySearchDictionaryMap = this._getSecondarySearchDictionaryMap(enabledDictionaryMap); - await this._addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap); + await this._addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator); for (const group of groupedDictionaryEntries) { this._sortTermDictionaryEntriesById(group.dictionaryEntries); } if (ungroupedDictionaryEntriesMap.size !== 0 || secondarySearchDictionaryMap.size !== 0) { - await this._addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap); + await this._addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator); } } const newDictionaryEntries = []; for (const group of groupedDictionaryEntries) { - newDictionaryEntries.push(this._createGroupedDictionaryEntry(group.dictionaryEntries, true)); + newDictionaryEntries.push(this._createGroupedDictionaryEntry(group.dictionaryEntries, true, tagAggregator)); } - newDictionaryEntries.push(...this._groupDictionaryEntriesByHeadword(ungroupedDictionaryEntriesMap.values())); + newDictionaryEntries.push(...this._groupDictionaryEntriesByHeadword(ungroupedDictionaryEntriesMap.values(), tagAggregator)); return newDictionaryEntries; } - async _addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap) { + /** + * @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries + * @param {Map<number, import('dictionary').TermDictionaryEntry>} ungroupedDictionaryEntriesMap + * @param {import('translator').SequenceQuery[]} sequenceList + * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap + * @param {TranslatorTagAggregator} tagAggregator + */ + async _addRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, sequenceList, enabledDictionaryMap, tagAggregator) { const databaseEntries = await this._database.findTermsBySequenceBulk(sequenceList); for (const databaseEntry of databaseEntries) { const {dictionaryEntries, ids} = groupedDictionaryEntries[databaseEntry.index]; @@ -425,15 +490,23 @@ export class Translator { if (ids.has(id)) { continue; } const {term} = databaseEntry; - const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, term, term, term, [], false, enabledDictionaryMap); + const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, term, term, term, [], false, enabledDictionaryMap, tagAggregator); dictionaryEntries.push(dictionaryEntry); ids.add(id); ungroupedDictionaryEntriesMap.delete(id); } } - async _addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap) { + /** + * @param {import('translator').DictionaryEntryGroup[]} groupedDictionaryEntries + * @param {Map<number, import('dictionary').TermDictionaryEntry>} ungroupedDictionaryEntriesMap + * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap + * @param {import('translation').TermEnabledDictionaryMap} secondarySearchDictionaryMap + * @param {TranslatorTagAggregator} tagAggregator + */ + async _addSecondaryRelatedDictionaryEntries(groupedDictionaryEntries, ungroupedDictionaryEntriesMap, enabledDictionaryMap, secondarySearchDictionaryMap, tagAggregator) { // Prepare grouping info + /** @type {import('dictionary-database').TermExactRequest[]} */ const termList = []; const targetList = []; const targetMap = new Map(); @@ -484,7 +557,7 @@ export class Translator { for (const {ids, dictionaryEntries} of target.groups) { if (ids.has(id)) { continue; } - const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, sourceText, sourceText, sourceText, [], false, enabledDictionaryMap); + const dictionaryEntry = this._createTermDictionaryEntryFromDatabaseEntry(databaseEntry, sourceText, sourceText, sourceText, [], false, enabledDictionaryMap, tagAggregator); dictionaryEntries.push(dictionaryEntry); ids.add(id); ungroupedDictionaryEntriesMap.delete(id); @@ -492,7 +565,12 @@ export class Translator { } } - _groupDictionaryEntriesByHeadword(dictionaryEntries) { + /** + * @param {Iterable<import('dictionary').TermDictionaryEntry>} dictionaryEntries + * @param {TranslatorTagAggregator} tagAggregator + * @returns {import('dictionary').TermDictionaryEntry[]} + */ + _groupDictionaryEntriesByHeadword(dictionaryEntries, tagAggregator) { const groups = new Map(); for (const dictionaryEntry of dictionaryEntries) { const {inflections, headwords: [{term, reading}]} = dictionaryEntry; @@ -507,13 +585,17 @@ export class Translator { const newDictionaryEntries = []; for (const groupDictionaryEntries of groups.values()) { - newDictionaryEntries.push(this._createGroupedDictionaryEntry(groupDictionaryEntries, false)); + newDictionaryEntries.push(this._createGroupedDictionaryEntry(groupDictionaryEntries, false, tagAggregator)); } return newDictionaryEntries; } // Removing data + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + * @param {Set<string>} excludeDictionaryDefinitions + */ _removeExcludedDefinitions(dictionaryEntries, excludeDictionaryDefinitions) { for (let i = dictionaryEntries.length - 1; i >= 0; --i) { const dictionaryEntry = dictionaryEntries[i]; @@ -534,6 +616,9 @@ export class Translator { } } + /** + * @param {import('dictionary').TermDictionaryEntry} dictionaryEntry + */ _removeUnusedHeadwords(dictionaryEntry) { const {definitions, pronunciations, frequencies, headwords} = dictionaryEntry; const removeHeadwordIndices = new Set(); @@ -548,6 +633,7 @@ export class Translator { if (removeHeadwordIndices.size === 0) { return; } + /** @type {Map<number, number>} */ const indexRemap = new Map(); let oldIndex = 0; for (let i = 0, ii = headwords.length; i < ii; ++i) { @@ -566,6 +652,10 @@ export class Translator { this._updateArrayItemsHeadwordIndex(frequencies, indexRemap); } + /** + * @param {import('dictionary').TermDefinition[]} definitions + * @param {Map<number, number>} indexRemap + */ _updateDefinitionHeadwordIndices(definitions, indexRemap) { for (const {headwordIndices} of definitions) { for (let i = headwordIndices.length - 1; i >= 0; --i) { @@ -579,6 +669,10 @@ export class Translator { } } + /** + * @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]} array + * @param {Map<number, number>} indexRemap + */ _updateArrayItemsHeadwordIndex(array, indexRemap) { for (let i = array.length - 1; i >= 0; --i) { const item = array[i]; @@ -592,6 +686,11 @@ export class Translator { } } + /** + * @param {import('dictionary').TermPronunciation[]|import('dictionary').TermFrequency[]|import('dictionary').TermDefinition[]} array + * @param {Set<string>} excludeDictionaryDefinitions + * @returns {boolean} + */ _removeArrayItemsWithDictionary(array, excludeDictionaryDefinitions) { let changed = false; for (let j = array.length - 1; j >= 0; --j) { @@ -603,45 +702,48 @@ export class Translator { return changed; } - _removeTagGroupsWithDictionary(array, excludeDictionaryDefinitions) { - for (const {tags} of array) { - this._removeArrayItemsWithDictionary(tags, excludeDictionaryDefinitions); + /** + * @param {import('dictionary').Tag[]} array + * @param {Set<string>} excludeDictionaryDefinitions + * @returns {boolean} + */ + _removeArrayItemsWithDictionary2(array, excludeDictionaryDefinitions) { + let changed = false; + for (let j = array.length - 1; j >= 0; --j) { + const {dictionaries} = array[j]; + if (this._hasAny(excludeDictionaryDefinitions, dictionaries)) { continue; } + array.splice(j, 1); + changed = true; } + return changed; } - // Tags - - _getTermTagTargets(dictionaryEntries) { - const tagTargets = []; - for (const {headwords, definitions, pronunciations} of dictionaryEntries) { - this._addTagExpansionTargets(tagTargets, headwords); - this._addTagExpansionTargets(tagTargets, definitions); - for (const {pitches} of pronunciations) { - this._addTagExpansionTargets(tagTargets, pitches); - } + /** + * @param {import('dictionary').TermDefinition[]|import('dictionary').TermHeadword[]} array + * @param {Set<string>} excludeDictionaryDefinitions + */ + _removeTagGroupsWithDictionary(array, excludeDictionaryDefinitions) { + for (const {tags} of array) { + this._removeArrayItemsWithDictionary2(tags, excludeDictionaryDefinitions); } - return tagTargets; - } - - _clearTermTags(dictionaryEntries) { - this._getTermTagTargets(dictionaryEntries); } - async _expandTermTags(dictionaryEntries) { - const tagTargets = this._getTermTagTargets(dictionaryEntries); - await this._expandTagGroups(tagTargets); - this._groupTags(tagTargets); - } + // Tags - async _expandKanjiTags(dictionaryEntries) { - const tagTargets = []; - this._addTagExpansionTargets(tagTargets, dictionaryEntries); - await this._expandTagGroups(tagTargets); - this._groupTags(tagTargets); + /** + * @param {import('translator').TagExpansionTarget[]} tagExpansionTargets + */ + async _expandTagGroupsAndGroup(tagExpansionTargets) { + await this._expandTagGroups(tagExpansionTargets); + this._groupTags(tagExpansionTargets); } + /** + * @param {import('translator').TagExpansionTarget[]} tagTargets + */ async _expandTagGroups(tagTargets) { const allItems = []; + /** @type {import('translator').TagTargetMap} */ const targetMap = new Map(); for (const {tagGroups, tags} of tagTargets) { for (const {dictionary, tagNames} of tagGroups) { @@ -687,10 +789,12 @@ export class Translator { const databaseTags = await this._database.findTagMetaBulk(nonCachedItems); for (let i = 0; i < nonCachedItemCount; ++i) { const item = nonCachedItems[i]; - let databaseTag = databaseTags[i]; - if (typeof databaseTag === 'undefined') { databaseTag = null; } - item.databaseTag = databaseTag; - item.cache.set(item.query, databaseTag); + const databaseTag = databaseTags[i]; + const databaseTag2 = typeof databaseTag !== 'undefined' ? databaseTag : null; + item.databaseTag = databaseTag2; + if (item.cache !== null) { + item.cache.set(item.query, databaseTag2); + } } } @@ -701,8 +805,16 @@ export class Translator { } } + /** + * @param {import('translator').TagExpansionTarget[]} tagTargets + */ _groupTags(tagTargets) { const stringComparer = this._stringComparer; + /** + * @param {import('dictionary').Tag} v1 + * @param {import('dictionary').Tag} v2 + * @returns {number} + */ const compare = (v1, v2) => { const i = v1.order - v2.order; return i !== 0 ? i : stringComparer.compare(v1.name, v2.name); @@ -715,16 +827,9 @@ export class Translator { } } - _addTagExpansionTargets(tagTargets, objects) { - for (const value of objects) { - const tagGroups = value.tags; - if (tagGroups.length === 0) { continue; } - const tags = []; - value.tags = tags; - tagTargets.push({tagGroups, tags}); - } - } - + /** + * @param {import('dictionary').Tag[]} tags + */ _mergeSimilarTags(tags) { let tagCount = tags.length; for (let i = 0; i < tagCount; ++i) { @@ -745,6 +850,11 @@ export class Translator { } } + /** + * @param {import('dictionary').Tag[]} tags + * @param {string} category + * @returns {string[]} + */ _getTagNamesWithCategory(tags, category) { const results = []; for (const tag of tags) { @@ -755,6 +865,9 @@ export class Translator { return results; } + /** + * @param {import('dictionary').TermDefinition[]} definitions + */ _flagRedundantDefinitionTags(definitions) { if (definitions.length === 0) { return; } @@ -789,7 +902,12 @@ export class Translator { // Metadata - async _addTermMeta(dictionaryEntries, enabledDictionaryMap) { + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap + * @param {TranslatorTagAggregator} tagAggregator + */ + async _addTermMeta(dictionaryEntries, enabledDictionaryMap, tagAggregator) { const headwordMap = new Map(); const headwordMapKeys = []; const headwordReadingMaps = []; @@ -821,16 +939,11 @@ export class Translator { switch (mode) { case 'freq': { - let frequency = data; const hasReading = (data !== null && typeof data === 'object' && typeof data.reading === 'string'); - if (hasReading) { - if (data.reading !== reading) { continue; } - frequency = data.frequency; - } + if (hasReading && data.reading !== reading) { continue; } + const frequency = hasReading ? data.frequency : /** @type {import('dictionary-data').GenericFrequencyData} */ (data); for (const {frequencies, headwordIndex} of targets) { - let displayValue; - let displayValueParsed; - ({frequency, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency)); + const {frequency: frequencyValue, displayValue, displayValueParsed} = this._getFrequencyInfo(frequency); frequencies.push(this._createTermFrequency( frequencies.length, headwordIndex, @@ -838,7 +951,7 @@ export class Translator { dictionaryIndex, dictionaryPriority, hasReading, - frequency, + frequencyValue, displayValue, displayValueParsed )); @@ -848,11 +961,13 @@ export class Translator { case 'pitch': { if (data.reading !== reading) { continue; } + /** @type {import('dictionary').TermPitch[]} */ const pitches = []; for (const {position, tags, nasal, devoice} of data.pitches) { + /** @type {import('dictionary').Tag[]} */ const tags2 = []; - if (Array.isArray(tags) && tags.length > 0) { - tags2.push(this._createTagGroup(dictionary, tags)); + if (Array.isArray(tags)) { + tagAggregator.addTags(tags2, dictionary, tags); } const nasalPositions = this._toNumberArray(nasal); const devoicePositions = this._toNumberArray(devoice); @@ -875,6 +990,10 @@ export class Translator { } } + /** + * @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries + * @param {import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap + */ async _addKanjiMeta(dictionaryEntries, enabledDictionaryMap) { const kanjiList = []; for (const {character} of dictionaryEntries) { @@ -905,6 +1024,11 @@ export class Translator { } } + /** + * @param {{[key: string]: (string|number)}} stats + * @param {string} dictionary + * @returns {Promise<import('dictionary').KanjiStatGroups>} + */ async _expandKanjiStats(stats, dictionary) { const statsEntries = Object.entries(stats); const items = []; @@ -915,10 +1039,11 @@ export class Translator { const databaseInfos = await this._database.findTagMetaBulk(items); + /** @type {Map<string, import('dictionary').KanjiStat[]>} */ const statsGroups = new Map(); for (let i = 0, ii = statsEntries.length; i < ii; ++i) { const databaseInfo = databaseInfos[i]; - if (databaseInfo === null) { continue; } + if (typeof databaseInfo === 'undefined') { continue; } const [name, value] = statsEntries[i]; const {category} = databaseInfo; @@ -931,6 +1056,7 @@ export class Translator { group.push(this._createKanjiStat(name, value, databaseInfo, dictionary)); } + /** @type {import('dictionary').KanjiStatGroups} */ const groupedStats = {}; for (const [category, group] of statsGroups.entries()) { this._sortKanjiStats(group); @@ -939,6 +1065,9 @@ export class Translator { return groupedStats; } + /** + * @param {import('dictionary').KanjiStat[]} stats + */ _sortKanjiStats(stats) { if (stats.length <= 1) { return; } const stringComparer = this._stringComparer; @@ -948,45 +1077,59 @@ export class Translator { }); } + /** + * @param {string} value + * @returns {number} + */ _convertStringToNumber(value) { const match = this._numberRegex.exec(value); if (match === null) { return 0; } - value = Number.parseFloat(match[0]); - return Number.isFinite(value) ? value : 0; + const result = Number.parseFloat(match[0]); + return Number.isFinite(result) ? result : 0; } + /** + * @param {import('dictionary-data').GenericFrequencyData} frequency + * @returns {{frequency: number, displayValue: ?string, displayValueParsed: boolean}} + */ _getFrequencyInfo(frequency) { + let frequencyValue = 0; let displayValue = null; let displayValueParsed = false; if (typeof frequency === 'object' && frequency !== null) { - ({value: frequency, displayValue} = frequency); - if (typeof frequency !== 'number') { frequency = 0; } - if (typeof displayValue !== 'string') { displayValue = null; } + const {value: frequencyValue2, displayValue: displayValue2} = frequency; + if (typeof frequencyValue2 === 'number') { frequencyValue = frequencyValue2; } + if (typeof displayValue2 === 'string') { displayValue = displayValue2; } } else { switch (typeof frequency) { case 'number': - // No change + frequencyValue = frequency; break; case 'string': displayValue = frequency; displayValueParsed = true; - frequency = this._convertStringToNumber(frequency); - break; - default: - frequency = 0; + frequencyValue = this._convertStringToNumber(frequency); break; } } - return {frequency, displayValue, displayValueParsed}; + return {frequency: frequencyValue, displayValue, displayValueParsed}; } // Helpers + /** + * @param {string} name + * @returns {string} + */ _getNameBase(name) { const pos = name.indexOf(':'); return (pos >= 0 ? name.substring(0, pos) : name); } + /** + * @param {import('translation').TermEnabledDictionaryMap} enabledDictionaryMap + * @returns {import('translation').TermEnabledDictionaryMap} + */ _getSecondarySearchDictionaryMap(enabledDictionaryMap) { const secondarySearchDictionaryMap = new Map(); for (const [dictionary, details] of enabledDictionaryMap.entries()) { @@ -996,12 +1139,22 @@ export class Translator { return secondarySearchDictionaryMap; } + /** + * @param {string} dictionary + * @param {import('translation').TermEnabledDictionaryMap|import('translation').KanjiEnabledDictionaryMap} enabledDictionaryMap + * @returns {{index: number, priority: number}} + */ _getDictionaryOrder(dictionary, enabledDictionaryMap) { const info = enabledDictionaryMap.get(dictionary); const {index, priority} = typeof info !== 'undefined' ? info : {index: enabledDictionaryMap.size, priority: 0}; return {index, priority}; } + /** + * @param {[...args: unknown[][]]} arrayVariants + * @yields {[...args: unknown[]]} + * @returns {Generator<unknown[], void, unknown>} + */ *_getArrayVariants(arrayVariants) { const ii = arrayVariants.length; @@ -1022,16 +1175,31 @@ export class Translator { } } + /** + * @param {unknown[]} array + * @returns {string} + */ _createMapKey(array) { return JSON.stringify(array); } + /** + * @param {number|number[]|undefined} value + * @returns {number[]} + */ _toNumberArray(value) { return Array.isArray(value) ? value : (typeof value === 'number' ? [value] : []); } // Kanji data + /** + * @param {string} name + * @param {string|number} value + * @param {import('dictionary-database').Tag} databaseInfo + * @param {string} dictionary + * @returns {import('dictionary').KanjiStat} + */ _createKanjiStat(name, value, databaseInfo, dictionary) { const {category, notes, order, score} = databaseInfo; return { @@ -1040,23 +1208,43 @@ export class Translator { content: (typeof notes === 'string' ? notes : ''), order: (typeof order === 'number' ? order : 0), score: (typeof score === 'number' ? score : 0), - dictionary: (typeof dictionary === 'string' ? dictionary : null), + dictionary, value }; } + /** + * @param {number} index + * @param {string} dictionary + * @param {number} dictionaryIndex + * @param {number} dictionaryPriority + * @param {string} character + * @param {number} frequency + * @param {?string} displayValue + * @param {boolean} displayValueParsed + * @returns {import('dictionary').KanjiFrequency} + */ _createKanjiFrequency(index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed) { return {index, dictionary, dictionaryIndex, dictionaryPriority, character, frequency, displayValue, displayValueParsed}; } - _createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, tags, stats, definitions) { + /** + * @param {string} character + * @param {string} dictionary + * @param {string[]} onyomi + * @param {string[]} kunyomi + * @param {import('dictionary').KanjiStatGroups} stats + * @param {string[]} definitions + * @returns {import('dictionary').KanjiDictionaryEntry} + */ + _createKanjiDictionaryEntry(character, dictionary, onyomi, kunyomi, stats, definitions) { return { type: 'kanji', character, dictionary, onyomi, kunyomi, - tags, + tags: [], stats, definitions, frequencies: [] @@ -1065,8 +1253,17 @@ export class Translator { // Term data + /** + * @param {?import('dictionary-database').Tag} databaseTag + * @param {string} name + * @param {string} dictionary + * @returns {import('dictionary').Tag} + */ _createTag(databaseTag, name, dictionary) { - const {category, notes, order, score} = (databaseTag !== null ? databaseTag : {}); + let category, notes, order, score; + if (typeof databaseTag === 'object' && databaseTag !== null) { + ({category, notes, order, score} = databaseTag); + } return { name, category: (typeof category === 'string' && category.length > 0 ? category : 'default'), @@ -1078,18 +1275,46 @@ export class Translator { }; } - _createTagGroup(dictionary, tagNames) { - return {dictionary, tagNames}; - } - + /** + * @param {string} originalText + * @param {string} transformedText + * @param {string} deinflectedText + * @param {import('dictionary').TermSourceMatchType} matchType + * @param {import('dictionary').TermSourceMatchSource} matchSource + * @param {boolean} isPrimary + * @returns {import('dictionary').TermSource} + */ _createSource(originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary) { return {originalText, transformedText, deinflectedText, matchType, matchSource, isPrimary}; } + /** + * @param {number} index + * @param {string} term + * @param {string} reading + * @param {import('dictionary').TermSource[]} sources + * @param {import('dictionary').Tag[]} tags + * @param {string[]} wordClasses + * @returns {import('dictionary').TermHeadword} + */ _createTermHeadword(index, term, reading, sources, tags, wordClasses) { return {index, term, reading, sources, tags, wordClasses}; } + /** + * @param {number} index + * @param {number[]} headwordIndices + * @param {string} dictionary + * @param {number} dictionaryIndex + * @param {number} dictionaryPriority + * @param {number} id + * @param {number} score + * @param {number[]} sequences + * @param {boolean} isPrimary + * @param {import('dictionary').Tag[]} tags + * @param {import('dictionary-data').TermGlossary[]} entries + * @returns {import('dictionary').TermDefinition} + */ _createTermDefinition(index, headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, id, score, sequences, isPrimary, tags, entries) { return { index, @@ -1107,14 +1332,47 @@ export class Translator { }; } + /** + * @param {number} index + * @param {number} headwordIndex + * @param {string} dictionary + * @param {number} dictionaryIndex + * @param {number} dictionaryPriority + * @param {import('dictionary').TermPitch[]} pitches + * @returns {import('dictionary').TermPronunciation} + */ _createTermPronunciation(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches) { return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, pitches}; } + /** + * @param {number} index + * @param {number} headwordIndex + * @param {string} dictionary + * @param {number} dictionaryIndex + * @param {number} dictionaryPriority + * @param {boolean} hasReading + * @param {number} frequency + * @param {?string} displayValue + * @param {boolean} displayValueParsed + * @returns {import('dictionary').TermFrequency} + */ _createTermFrequency(index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed) { return {index, headwordIndex, dictionary, dictionaryIndex, dictionaryPriority, hasReading, frequency, displayValue, displayValueParsed}; } + /** + * @param {boolean} isPrimary + * @param {string[]} inflections + * @param {number} score + * @param {number} dictionaryIndex + * @param {number} dictionaryPriority + * @param {number} sourceTermExactMatchCount + * @param {number} maxTransformedTextLength + * @param {import('dictionary').TermHeadword[]} headwords + * @param {import('dictionary').TermDefinition[]} definitions + * @returns {import('dictionary').TermDictionaryEntry} + */ _createTermDictionaryEntry(isPrimary, inflections, score, dictionaryIndex, dictionaryPriority, sourceTermExactMatchCount, maxTransformedTextLength, headwords, definitions) { return { type: 'term', @@ -1133,7 +1391,18 @@ export class Translator { }; } - _createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, isPrimary, enabledDictionaryMap) { + /** + * @param {import('dictionary-database').TermEntry} databaseEntry + * @param {string} originalText + * @param {string} transformedText + * @param {string} deinflectedText + * @param {string[]} reasons + * @param {boolean} isPrimary + * @param {Map<string, import('translation').FindTermDictionary>} enabledDictionaryMap + * @param {TranslatorTagAggregator} tagAggregator + * @returns {import('dictionary').TermDictionaryEntry} + */ + _createTermDictionaryEntryFromDatabaseEntry(databaseEntry, originalText, transformedText, deinflectedText, reasons, isPrimary, enabledDictionaryMap, tagAggregator) { const {matchType, matchSource, term, reading: rawReading, definitionTags, termTags, definitions, score, dictionary, id, sequence: rawSequence, rules} = databaseEntry; const reading = (rawReading.length > 0 ? rawReading : term); const {index: dictionaryIndex, priority: dictionaryPriority} = this._getDictionaryOrder(dictionary, enabledDictionaryMap); @@ -1143,10 +1412,12 @@ export class Translator { const hasSequence = (rawSequence >= 0); const sequence = hasSequence ? rawSequence : -1; + /** @type {import('dictionary').Tag[]} */ const headwordTagGroups = []; + /** @type {import('dictionary').Tag[]} */ const definitionTagGroups = []; - if (termTags.length > 0) { headwordTagGroups.push(this._createTagGroup(dictionary, termTags)); } - if (definitionTags.length > 0) { definitionTagGroups.push(this._createTagGroup(dictionary, definitionTags)); } + tagAggregator.addTags(headwordTagGroups, dictionary, termTags); + tagAggregator.addTags(definitionTagGroups, dictionary, definitionTags); return this._createTermDictionaryEntry( isPrimary, @@ -1161,12 +1432,19 @@ export class Translator { ); } - _createGroupedDictionaryEntry(dictionaryEntries, checkDuplicateDefinitions) { + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + * @param {boolean} checkDuplicateDefinitions + * @param {TranslatorTagAggregator} tagAggregator + * @returns {import('dictionary').TermDictionaryEntry} + */ + _createGroupedDictionaryEntry(dictionaryEntries, checkDuplicateDefinitions, tagAggregator) { // Headwords are generated before sorting, so that the order of dictionaryEntries can be maintained const definitionEntries = []; + /** @type {Map<string, import('dictionary').TermHeadword>} */ const headwords = new Map(); for (const dictionaryEntry of dictionaryEntries) { - const headwordIndexMap = this._addTermHeadwords(headwords, dictionaryEntry.headwords); + const headwordIndexMap = this._addTermHeadwords(headwords, dictionaryEntry.headwords, tagAggregator); definitionEntries.push({index: definitionEntries.length, dictionaryEntry, headwordIndexMap}); } @@ -1181,7 +1459,9 @@ export class Translator { let dictionaryPriority = Number.MIN_SAFE_INTEGER; let maxTransformedTextLength = 0; let isPrimary = false; + /** @type {import('dictionary').TermDefinition[]} */ const definitions = []; + /** @type {?Map<string, import('dictionary').TermDefinition>} */ const definitionsMap = checkDuplicateDefinitions ? new Map() : null; let inflections = null; @@ -1197,8 +1477,8 @@ export class Translator { inflections = dictionaryEntryInflections; } } - if (checkDuplicateDefinitions) { - this._addTermDefinitions(definitions, definitionsMap, dictionaryEntry.definitions, headwordIndexMap); + if (definitionsMap !== null) { + this._addTermDefinitions(definitions, definitionsMap, dictionaryEntry.definitions, headwordIndexMap, tagAggregator); } else { this._addTermDefinitionsFast(definitions, dictionaryEntry.definitions, headwordIndexMap); } @@ -1231,6 +1511,11 @@ export class Translator { // Data collection addition functions + /** + * @template [T=unknown] + * @param {T[]} list + * @param {T[]} newItems + */ _addUniqueSimple(list, newItems) { for (const item of newItems) { if (!list.includes(item)) { @@ -1239,6 +1524,10 @@ export class Translator { } } + /** + * @param {import('dictionary').TermSource[]} sources + * @param {import('dictionary').TermSource[]} newSources + */ _addUniqueSources(sources, newSources) { if (newSources.length === 0) { return; } if (sources.length === 0) { @@ -1267,27 +1556,14 @@ export class Translator { } } - _addUniqueTagGroups(tagGroups, newTagGroups) { - if (newTagGroups.length === 0) { return; } - for (const newTagGroup of newTagGroups) { - const {dictionary} = newTagGroup; - const ii = tagGroups.length; - if (ii > 0) { - let i = 0; - for (; i < ii; ++i) { - const tagGroup = tagGroups[i]; - if (tagGroup.dictionary === dictionary) { - this._addUniqueSimple(tagGroup.tagNames, newTagGroup.tagNames); - break; - } - } - if (i < ii) { continue; } - } - tagGroups.push(newTagGroup); - } - } - - _addTermHeadwords(headwordsMap, headwords) { + /** + * @param {Map<string, import('dictionary').TermHeadword>} headwordsMap + * @param {import('dictionary').TermHeadword[]} headwords + * @param {TranslatorTagAggregator} tagAggregator + * @returns {number[]} + */ + _addTermHeadwords(headwordsMap, headwords, tagAggregator) { + /** @type {number[]} */ const headwordIndexMap = []; for (const {term, reading, sources, tags, wordClasses} of headwords) { const key = this._createMapKey([term, reading]); @@ -1297,13 +1573,17 @@ export class Translator { headwordsMap.set(key, headword); } this._addUniqueSources(headword.sources, sources); - this._addUniqueTagGroups(headword.tags, tags); this._addUniqueSimple(headword.wordClasses, wordClasses); + tagAggregator.mergeTags(headword.tags, tags); headwordIndexMap.push(headword.index); } return headwordIndexMap; } + /** + * @param {number[]} headwordIndices + * @param {number} headwordIndex + */ _addUniqueTermHeadwordIndex(headwordIndices, headwordIndex) { let end = headwordIndices.length; if (end === 0) { @@ -1327,6 +1607,11 @@ export class Translator { headwordIndices.splice(start, 0, headwordIndex); } + /** + * @param {import('dictionary').TermDefinition[]} definitions + * @param {import('dictionary').TermDefinition[]} newDefinitions + * @param {number[]} headwordIndexMap + */ _addTermDefinitionsFast(definitions, newDefinitions, headwordIndexMap) { for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) { const headwordIndicesNew = []; @@ -1337,7 +1622,14 @@ export class Translator { } } - _addTermDefinitions(definitions, definitionsMap, newDefinitions, headwordIndexMap) { + /** + * @param {import('dictionary').TermDefinition[]} definitions + * @param {Map<string, import('dictionary').TermDefinition>} definitionsMap + * @param {import('dictionary').TermDefinition[]} newDefinitions + * @param {number[]} headwordIndexMap + * @param {TranslatorTagAggregator} tagAggregator + */ + _addTermDefinitions(definitions, definitionsMap, newDefinitions, headwordIndexMap, tagAggregator) { for (const {headwordIndices, dictionary, dictionaryIndex, dictionaryPriority, sequences, id, score, isPrimary, tags, entries} of newDefinitions) { const key = this._createMapKey([dictionary, ...entries]); let definition = definitionsMap.get(key); @@ -1356,19 +1648,36 @@ export class Translator { for (const headwordIndex of headwordIndices) { this._addUniqueTermHeadwordIndex(newHeadwordIndices, headwordIndexMap[headwordIndex]); } - this._addUniqueTagGroups(definition.tags, tags); + tagAggregator.mergeTags(definition.tags, tags); } } // Sorting functions + /** + * @param {import('dictionary-database').TermEntry[]|import('dictionary-database').KanjiEntry[]} databaseEntries + */ _sortDatabaseEntriesByIndex(databaseEntries) { if (databaseEntries.length <= 1) { return; } - databaseEntries.sort((a, b) => a.index - b.index); + /** + * @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v1 + * @param {import('dictionary-database').TermEntry|import('dictionary-database').KanjiEntry} v2 + * @returns {number} + */ + const compareFunction = (v1, v2) => v1.index - v2.index; + databaseEntries.sort(compareFunction); } + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + */ _sortTermDictionaryEntries(dictionaryEntries) { const stringComparer = this._stringComparer; + /** + * @param {import('dictionary').TermDictionaryEntry} v1 + * @param {import('dictionary').TermDictionaryEntry} v2 + * @returns {number} + */ const compareFunction = (v1, v2) => { // Sort by length of source term let i = v2.maxTransformedTextLength - v1.maxTransformedTextLength; @@ -1419,7 +1728,15 @@ export class Translator { dictionaryEntries.sort(compareFunction); } + /** + * @param {import('dictionary').TermDefinition[]} definitions + */ _sortTermDictionaryEntryDefinitions(definitions) { + /** + * @param {import('dictionary').TermDefinition} v1 + * @param {import('dictionary').TermDefinition} v2 + * @returns {number} + */ const compareFunction = (v1, v2) => { // Sort by frequency order let i = v1.frequencyOrder - v2.frequencyOrder; @@ -1455,12 +1772,23 @@ export class Translator { definitions.sort(compareFunction); } + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + */ _sortTermDictionaryEntriesById(dictionaryEntries) { if (dictionaryEntries.length <= 1) { return; } dictionaryEntries.sort((a, b) => a.definitions[0].id - b.definitions[0].id); } + /** + * @param {import('dictionary').TermFrequency[]|import('dictionary').TermPronunciation[]} dataList + */ _sortTermDictionaryEntrySimpleData(dataList) { + /** + * @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v1 + * @param {import('dictionary').TermFrequency|import('dictionary').TermPronunciation} v2 + * @returns {number} + */ const compare = (v1, v2) => { // Sort by dictionary priority let i = v2.dictionaryPriority - v1.dictionaryPriority; @@ -1481,7 +1809,15 @@ export class Translator { dataList.sort(compare); } + /** + * @param {import('dictionary').KanjiDictionaryEntry[]} dictionaryEntries + */ _sortKanjiDictionaryEntryData(dictionaryEntries) { + /** + * @param {import('dictionary').KanjiFrequency} v1 + * @param {import('dictionary').KanjiFrequency} v2 + * @returns {number} + */ const compare = (v1, v2) => { // Sort by dictionary priority let i = v2.dictionaryPriority - v1.dictionaryPriority; @@ -1501,6 +1837,11 @@ export class Translator { } } + /** + * @param {import('dictionary').TermDictionaryEntry[]} dictionaryEntries + * @param {string} dictionary + * @param {boolean} ascending + */ _updateSortFrequencies(dictionaryEntries, dictionary, ascending) { const frequencyMap = new Map(); for (const dictionaryEntry of dictionaryEntries) { @@ -1539,4 +1880,102 @@ export class Translator { frequencyMap.clear(); } } + + // Miscellaneous + + /** + * @template T + * @param {Set<T>} set + * @param {T[]} values + * @returns {boolean} + */ + _hasAny(set, values) { + for (const value of values) { + if (set.has(value)) { return true; } + } + return false; + } +} + +class TranslatorTagAggregator { + constructor() { + /** @type {Map<import('dictionary').Tag[], import('translator').TagGroup[]>} */ + this._tagExpansionTargetMap = new Map(); + } + + /** + * @param {import('dictionary').Tag[]} tags + * @param {string} dictionary + * @param {string[]} tagNames + */ + addTags(tags, dictionary, tagNames) { + if (tagNames.length === 0) { return; } + const tagGroups = this._getOrCreateTagGroups(tags); + const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary); + this._addUniqueTags(tagGroup, tagNames); + } + + /** + * @returns {import('translator').TagExpansionTarget[]} + */ + getTagExpansionTargets() { + const results = []; + for (const [tags, tagGroups] of this._tagExpansionTargetMap) { + results.push({tags, tagGroups}); + } + return results; + } + + /** + * @param {import('dictionary').Tag[]} tags + * @param {import('dictionary').Tag[]} newTags + */ + mergeTags(tags, newTags) { + const newTagGroups = this._tagExpansionTargetMap.get(newTags); + if (typeof newTagGroups === 'undefined') { return; } + const tagGroups = this._getOrCreateTagGroups(tags); + for (const {dictionary, tagNames} of newTagGroups) { + const tagGroup = this._getOrCreateTagGroup(tagGroups, dictionary); + this._addUniqueTags(tagGroup, tagNames); + } + } + + /** + * @param {import('dictionary').Tag[]} tags + * @returns {import('translator').TagGroup[]} + */ + _getOrCreateTagGroups(tags) { + let tagGroups = this._tagExpansionTargetMap.get(tags); + if (typeof tagGroups === 'undefined') { + tagGroups = []; + this._tagExpansionTargetMap.set(tags, tagGroups); + } + return tagGroups; + } + + /** + * @param {import('translator').TagGroup[]} tagGroups + * @param {string} dictionary + * @returns {import('translator').TagGroup} + */ + _getOrCreateTagGroup(tagGroups, dictionary) { + for (const tagGroup of tagGroups) { + if (tagGroup.dictionary === dictionary) { return tagGroup; } + } + const newTagGroup = {dictionary, tagNames: []}; + tagGroups.push(newTagGroup); + return newTagGroup; + } + + /** + * @param {import('translator').TagGroup} tagGroup + * @param {string[]} newTagNames + */ + _addUniqueTags(tagGroup, newTagNames) { + const {tagNames} = tagGroup; + for (const tagName of newTagNames) { + if (tagNames.includes(tagName)) { continue; } + tagNames.push(tagName); + } + } } diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js index 1720a5d9..e041cc67 100644 --- a/ext/js/media/audio-downloader.js +++ b/ext/js/media/audio-downloader.js @@ -17,17 +17,25 @@ */ import {RequestBuilder} from '../background/request-builder.js'; +import {ExtensionError} from '../core/extension-error.js'; import {JsonSchema} from '../data/json-schema.js'; import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js'; import {SimpleDOMParser} from '../dom/simple-dom-parser.js'; export class AudioDownloader { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, requestBuilder: RequestBuilder}} details + */ constructor({japaneseUtil, requestBuilder}) { + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {RequestBuilder} */ this._requestBuilder = requestBuilder; + /** @type {?JsonSchema} */ this._customAudioListSchema = null; - this._getInfoHandlers = new Map([ + /** @type {Map<import('settings').AudioSourceType, import('audio-downloader').GetInfoHandler>} */ + this._getInfoHandlers = new Map(/** @type {[name: import('settings').AudioSourceType, handler: import('audio-downloader').GetInfoHandler][]} */ ([ ['jpod101', this._getInfoJpod101.bind(this)], ['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)], ['jisho', this._getInfoJisho.bind(this)], @@ -35,9 +43,15 @@ export class AudioDownloader { ['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)], ['custom', this._getInfoCustom.bind(this)], ['custom-json', this._getInfoCustomJson.bind(this)] - ]); + ])); } + /** + * @param {import('audio').AudioSourceInfo} source + * @param {string} term + * @param {string} reading + * @returns {Promise<import('audio-downloader').Info[]>} + */ async getTermAudioInfoList(source, term, reading) { const handler = this._getInfoHandlers.get(source.type); if (typeof handler === 'function') { @@ -50,6 +64,14 @@ export class AudioDownloader { return []; } + /** + * @param {import('audio').AudioSourceInfo[]} sources + * @param {?number} preferredAudioIndex + * @param {string} term + * @param {string} reading + * @param {?number} idleTimeout + * @returns {Promise<import('audio-downloader').AudioBinaryBase64>} + */ async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout) { const errors = []; for (const source of sources) { @@ -70,28 +92,34 @@ export class AudioDownloader { } } - const error = new Error('Could not download audio'); + const error = new ExtensionError('Could not download audio'); error.data = {errors}; throw error; } // Private + /** + * @param {string} url + * @param {string} base + * @returns {string} + */ _normalizeUrl(url, base) { return new URL(url, base).href; } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJpod101(term, reading) { if (reading === term && this._japaneseUtil.isStringEntirelyKana(term)) { reading = term; - term = null; + term = ''; } const params = new URLSearchParams(); - if (term) { + if (term.length > 0) { params.set('kanji', term); } - if (reading) { + if (reading.length > 0) { params.set('kana', reading); } @@ -99,6 +127,7 @@ export class AudioDownloader { return [{type: 'url', url}]; } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJpod101Alternate(term, reading) { const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; const data = new URLSearchParams({ @@ -149,6 +178,7 @@ export class AudioDownloader { throw new Error('Failed to find audio URL'); } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJisho(term, reading) { const fetchUrl = `https://jisho.org/search/${term}`; const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { @@ -181,26 +211,52 @@ export class AudioDownloader { throw new Error('Failed to find audio URL'); } - async _getInfoTextToSpeech(term, reading, {voice}) { - if (!voice) { - throw new Error('No voice'); + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoTextToSpeech(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + const {voice} = details; + if (typeof voice !== 'string') { + throw new Error('Invalid voice'); } return [{type: 'tts', text: term, voice: voice}]; } - async _getInfoTextToSpeechReading(term, reading, {voice}) { - if (!voice) { - throw new Error('No voice'); + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoTextToSpeechReading(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + const {voice} = details; + if (typeof voice !== 'string') { + throw new Error('Invalid voice'); } return [{type: 'tts', text: reading, voice: voice}]; } - async _getInfoCustom(term, reading, {url}) { + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoCustom(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + let {url} = details; + if (typeof url !== 'string') { + throw new Error('Invalid url'); + } url = this._getCustomUrl(term, reading, url); return [{type: 'url', url}]; } - async _getInfoCustomJson(term, reading, {url}) { + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoCustomJson(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + let {url} = details; + if (typeof url !== 'string') { + throw new Error('Invalid url'); + } url = this._getCustomUrl(term, reading, url); const response = await this._requestBuilder.fetchAnonymous(url, { @@ -220,12 +276,14 @@ export class AudioDownloader { if (this._customAudioListSchema === null) { const schema = await this._getCustomAudioListSchema(); - this._customAudioListSchema = new JsonSchema(schema); + this._customAudioListSchema = new JsonSchema(/** @type {import('json-schema').Schema} */ (schema)); } this._customAudioListSchema.validate(responseJson); + /** @type {import('audio-downloader').Info[]} */ const results = []; for (const {url: url2, name} of responseJson.audioSources) { + /** @type {import('audio-downloader').Info1} */ const info = {type: 'url', url: url2}; if (typeof name === 'string') { info.name = name; } results.push(info); @@ -233,17 +291,32 @@ export class AudioDownloader { return results; } + /** + * @param {string} term + * @param {string} reading + * @param {string} url + * @returns {string} + * @throws {Error} + */ _getCustomUrl(term, reading, url) { if (typeof url !== 'string') { throw new Error('No custom URL defined'); } const data = {term, reading}; - return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0)); + return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[/** @type {'term'|'reading'} */ (m1)]}` : m0)); } + /** + * @param {string} url + * @param {import('settings').AudioSourceType} sourceType + * @param {?number} idleTimeout + * @returns {Promise<import('audio-downloader').AudioBinaryBase64>} + */ async _downloadAudioFromUrl(url, sourceType, idleTimeout) { let signal; + /** @type {?import('request-builder.js').ProgressCallback} */ let onProgress = null; + /** @type {?import('core').Timeout} */ let idleTimer = null; if (typeof idleTimeout === 'number') { const abortController = new AbortController(); @@ -252,7 +325,9 @@ export class AudioDownloader { abortController.abort('Idle timeout'); }; onProgress = (done) => { - clearTimeout(idleTimer); + if (idleTimer !== null) { + clearTimeout(idleTimer); + } idleTimer = done ? null : setTimeout(onIdleTimeout, idleTimeout); }; idleTimer = setTimeout(onIdleTimeout, idleTimeout); @@ -287,6 +362,11 @@ export class AudioDownloader { return {data, contentType}; } + /** + * @param {ArrayBuffer} arrayBuffer + * @param {import('settings').AudioSourceType} sourceType + * @returns {Promise<boolean>} + */ async _isAudioBinaryValid(arrayBuffer, sourceType) { switch (sourceType) { case 'jpod101': @@ -304,6 +384,10 @@ export class AudioDownloader { } } + /** + * @param {ArrayBuffer} arrayBuffer + * @returns {Promise<string>} + */ async _arrayBufferDigest(arrayBuffer) { const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer))); let digest = ''; @@ -313,6 +397,11 @@ export class AudioDownloader { return digest; } + /** + * @param {string} content + * @returns {import('simple-dom-parser').ISimpleDomParser} + * @throws {Error} + */ _createSimpleDOMParser(content) { if (typeof NativeSimpleDOMParser !== 'undefined' && NativeSimpleDOMParser.isSupported()) { return new NativeSimpleDOMParser(content); @@ -323,6 +412,9 @@ export class AudioDownloader { } } + /** + * @returns {Promise<unknown>} + */ async _getCustomAudioListSchema() { const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json'); const response = await fetch(url, { diff --git a/ext/js/media/audio-system.js b/ext/js/media/audio-system.js index 55812bec..1e8f1be2 100644 --- a/ext/js/media/audio-system.js +++ b/ext/js/media/audio-system.js @@ -19,12 +19,19 @@ import {EventDispatcher} from '../core.js'; import {TextToSpeechAudio} from './text-to-speech-audio.js'; +/** + * @augments EventDispatcher<import('audio-system').EventType> + */ export class AudioSystem extends EventDispatcher { constructor() { super(); + /** @type {?HTMLAudioElement} */ this._fallbackAudio = null; } + /** + * @returns {void} + */ prepare() { // speechSynthesis.getVoices() will not be populated unless some API call is made. if ( @@ -35,6 +42,9 @@ export class AudioSystem extends EventDispatcher { } } + /** + * @returns {HTMLAudioElement} + */ getFallbackAudio() { if (this._fallbackAudio === null) { this._fallbackAudio = new Audio('/data/audio/button.mp3'); @@ -42,6 +52,11 @@ export class AudioSystem extends EventDispatcher { return this._fallbackAudio; } + /** + * @param {string} url + * @param {import('settings').AudioSourceType} sourceType + * @returns {Promise<HTMLAudioElement>} + */ async createAudio(url, sourceType) { const audio = new Audio(url); await this._waitForData(audio); @@ -51,6 +66,12 @@ export class AudioSystem extends EventDispatcher { return audio; } + /** + * @param {string} text + * @param {string} voiceUri + * @returns {TextToSpeechAudio} + * @throws {Error} + */ createTextToSpeechAudio(text, voiceUri) { const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); if (voice === null) { @@ -61,10 +82,17 @@ export class AudioSystem extends EventDispatcher { // Private - _onVoicesChanged(e) { - this.trigger('voiceschanged', e); + /** + * @param {Event} event + */ + _onVoicesChanged(event) { + this.trigger('voiceschanged', event); } + /** + * @param {HTMLAudioElement} audio + * @returns {Promise<void>} + */ _waitForData(audio) { return new Promise((resolve, reject) => { audio.addEventListener('loadeddata', () => resolve()); @@ -72,6 +100,11 @@ export class AudioSystem extends EventDispatcher { }); } + /** + * @param {HTMLAudioElement} audio + * @param {import('settings').AudioSourceType} sourceType + * @returns {boolean} + */ _isAudioValid(audio, sourceType) { switch (sourceType) { case 'jpod101': @@ -87,6 +120,10 @@ export class AudioSystem extends EventDispatcher { } } + /** + * @param {string} voiceUri + * @returns {?SpeechSynthesisVoice} + */ _getTextToSpeechVoiceFromVoiceUri(voiceUri) { try { for (const voice of speechSynthesis.getVoices()) { diff --git a/ext/js/media/media-util.js b/ext/js/media/media-util.js index 843dc11c..1d70acd3 100644 --- a/ext/js/media/media-util.js +++ b/ext/js/media/media-util.js @@ -103,7 +103,7 @@ export class MediaUtil { /** * Gets the file extension for a corresponding media type. * @param {string} mediaType The media type to use. - * @returns {string} A file extension including the dot for the media type, + * @returns {?string} A file extension including the dot for the media type, * otherwise `null`. */ static getFileExtensionFromAudioMediaType(mediaType) { diff --git a/ext/js/media/text-to-speech-audio.js b/ext/js/media/text-to-speech-audio.js index ae717519..cd1205e5 100644 --- a/ext/js/media/text-to-speech-audio.js +++ b/ext/js/media/text-to-speech-audio.js @@ -17,13 +17,22 @@ */ export class TextToSpeechAudio { + /** + * @param {string} text + * @param {SpeechSynthesisVoice} voice + */ constructor(text, voice) { + /** @type {string} */ this._text = text; + /** @type {SpeechSynthesisVoice} */ this._voice = voice; + /** @type {?SpeechSynthesisUtterance} */ this._utterance = null; + /** @type {number} */ this._volume = 1; } + /** @type {number} */ get currentTime() { return 0; } @@ -32,6 +41,7 @@ export class TextToSpeechAudio { // NOP } + /** @type {number} */ get volume() { return this._volume; } @@ -43,6 +53,9 @@ export class TextToSpeechAudio { } } + /** + * @returns {Promise<void>} + */ async play() { try { if (this._utterance === null) { @@ -59,6 +72,9 @@ export class TextToSpeechAudio { } } + /** + * @returns {void} + */ pause() { try { speechSynthesis.cancel(); diff --git a/ext/js/pages/action-popup-main.js b/ext/js/pages/action-popup-main.js index 32bfcb7f..94b9b356 100644 --- a/ext/js/pages/action-popup-main.js +++ b/ext/js/pages/action-popup-main.js @@ -22,10 +22,13 @@ import {yomitan} from '../yomitan.js'; export class DisplayController { constructor() { + /** @type {?import('settings').Options} */ this._optionsFull = null; + /** @type {PermissionsUtil} */ this._permissionsUtil = new PermissionsUtil(); } + /** */ async prepare() { const manifest = chrome.runtime.getManifest(); @@ -39,7 +42,12 @@ export class DisplayController { this._setupHotkeys(); - const optionsPageUrl = manifest.options_ui.page; + const optionsPageUrl = ( + typeof manifest.options_ui === 'object' && + manifest.options_ui !== null && + typeof manifest.options_ui.page === 'string' ? + manifest.options_ui.page : '' + ); this._setupButtonEvents('.action-open-settings', 'openSettingsPage', chrome.runtime.getURL(optionsPageUrl)); this._setupButtonEvents('.action-open-permissions', null, chrome.runtime.getURL('/permissions.html')); @@ -49,7 +57,7 @@ export class DisplayController { this._setupOptions(primaryProfile); } - document.querySelector('.action-select-profile').hidden = (profiles.length <= 1); + /** @type {HTMLElement} */ (document.querySelector('.action-select-profile')).hidden = (profiles.length <= 1); this._updateProfileSelect(profiles, profileCurrent); @@ -60,13 +68,18 @@ export class DisplayController { // Private + /** + * @param {MouseEvent} e + */ _onSearchClick(e) { if (!e.shiftKey) { return; } e.preventDefault(); location.href = '/search.html?action-popup=true'; - return false; } + /** + * @param {chrome.runtime.Manifest} manifest + */ _showExtensionInfo(manifest) { const node = document.getElementById('extension-info'); if (node === null) { return; } @@ -74,11 +87,21 @@ export class DisplayController { node.textContent = `${manifest.name} v${manifest.version}`; } + /** + * @param {string} selector + * @param {?string} command + * @param {string} url + * @param {(event: MouseEvent) => void} [customHandler] + */ _setupButtonEvents(selector, command, url, customHandler) { + /** @type {NodeListOf<HTMLAnchorElement>} */ const nodes = document.querySelectorAll(selector); for (const node of nodes) { if (typeof command === 'string') { - node.addEventListener('click', (e) => { + /** + * @param {MouseEvent} e + */ + const onClick = (e) => { if (e.button !== 0) { return; } if (typeof customHandler === 'function') { const result = customHandler(e); @@ -86,12 +109,17 @@ export class DisplayController { } yomitan.api.commandExec(command, {mode: e.ctrlKey ? 'newTab' : 'existingOrNewTab'}); e.preventDefault(); - }, false); - node.addEventListener('auxclick', (e) => { + }; + /** + * @param {MouseEvent} e + */ + const onAuxClick = (e) => { if (e.button !== 1) { return; } yomitan.api.commandExec(command, {mode: 'newTab'}); e.preventDefault(); - }, false); + }; + node.addEventListener('click', onClick, false); + node.addEventListener('auxclick', onAuxClick, false); } if (typeof url === 'string') { @@ -102,6 +130,7 @@ export class DisplayController { } } + /** */ async _setupEnvironment() { const urlSearchParams = new URLSearchParams(location.search); let mode = urlSearchParams.get('mode'); @@ -129,6 +158,9 @@ export class DisplayController { document.documentElement.dataset.mode = mode; } + /** + * @returns {Promise<chrome.tabs.Tab|undefined>} + */ _getCurrentTab() { return new Promise((resolve, reject) => { chrome.tabs.getCurrent((result) => { @@ -142,10 +174,13 @@ export class DisplayController { }); } + /** + * @param {import('settings').Profile} profile + */ _setupOptions({options}) { const extensionEnabled = options.general.enable; const onToggleChanged = () => yomitan.api.commandExec('toggleTextScanning'); - for (const toggle of document.querySelectorAll('#enable-search,#enable-search2')) { + for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('#enable-search,#enable-search2'))) { toggle.checked = extensionEnabled; toggle.addEventListener('change', onToggleChanged, false); } @@ -153,11 +188,12 @@ export class DisplayController { this._updatePermissionsWarnings(options); } + /** */ async _setupHotkeys() { const hotkeyHelpController = new HotkeyHelpController(); await hotkeyHelpController.prepare(); - const {profiles, profileCurrent} = this._optionsFull; + const {profiles, profileCurrent} = /** @type {import('settings').Options} */ (this._optionsFull); const primaryProfile = (profileCurrent >= 0 && profileCurrent < profiles.length) ? profiles[profileCurrent] : null; if (primaryProfile !== null) { hotkeyHelpController.setOptions(primaryProfile.options); @@ -166,9 +202,13 @@ export class DisplayController { hotkeyHelpController.setupNode(document.documentElement); } + /** + * @param {import('settings').Profile[]} profiles + * @param {number} profileCurrent + */ _updateProfileSelect(profiles, profileCurrent) { - const select = document.querySelector('#profile-select'); - const optionGroup = document.querySelector('#profile-select-option-group'); + const select = /** @type {HTMLSelectElement} */ (document.querySelector('#profile-select')); + const optionGroup = /** @type {HTMLElement} */ (document.querySelector('#profile-select-option-group')); const fragment = document.createDocumentFragment(); for (let i = 0, ii = profiles.length; i < ii; ++i) { const {name} = profiles[i]; @@ -184,26 +224,37 @@ export class DisplayController { select.addEventListener('change', this._onProfileSelectChange.bind(this), false); } - _onProfileSelectChange(e) { - const value = parseInt(e.currentTarget.value, 10); - if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= this._optionsFull.profiles.length) { + /** + * @param {Event} event + */ + _onProfileSelectChange(event) { + const node = /** @type {HTMLInputElement} */ (event.currentTarget); + const value = parseInt(node.value, 10); + if (typeof value === 'number' && Number.isFinite(value) && value >= 0 && value <= /** @type {import('settings').Options} */ (this._optionsFull).profiles.length) { this._setPrimaryProfileIndex(value); } } + /** + * @param {number} value + */ async _setPrimaryProfileIndex(value) { - return await yomitan.api.modifySettings( - [{ - action: 'set', - path: 'profileCurrent', - value, - scope: 'global' - }] - ); + /** @type {import('settings-modifications').ScopedModificationSet} */ + const modification = { + action: 'set', + path: 'profileCurrent', + value, + scope: 'global', + optionsContext: null + }; + await yomitan.api.modifySettings([modification], 'action-popup'); } + /** + * @param {import('settings').ProfileOptions} options + */ async _updateDictionariesEnabledWarnings(options) { - const noDictionariesEnabledWarnings = document.querySelectorAll('.no-dictionaries-enabled-warning'); + const noDictionariesEnabledWarnings = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.no-dictionaries-enabled-warning')); const dictionaries = await yomitan.api.getDictionaryInfo(); const enabledDictionaries = new Set(); @@ -226,16 +277,20 @@ export class DisplayController { } } + /** + * @param {import('settings').ProfileOptions} options + */ async _updatePermissionsWarnings(options) { const permissions = await this._permissionsUtil.getAllPermissions(); if (this._permissionsUtil.hasRequiredPermissionsForOptions(permissions, options)) { return; } - const warnings = document.querySelectorAll('.action-open-permissions,.permissions-required-warning'); + const warnings = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.action-open-permissions,.permissions-required-warning')); for (const node of warnings) { node.hidden = false; } } + /** @returns {Promise<boolean>} */ async _isSafari() { const {browser} = await yomitan.api.getEnvironmentInfo(); return browser === 'safari'; diff --git a/ext/js/pages/common/extension-content-controller.js b/ext/js/pages/common/extension-content-controller.js index 3792130c..1c3f9c74 100644 --- a/ext/js/pages/common/extension-content-controller.js +++ b/ext/js/pages/common/extension-content-controller.js @@ -19,6 +19,7 @@ import {Environment} from '../../extension/environment.js'; export class ExtensionContentController { + /** */ prepare() { this._prepareSpecialUrls(); this._prepareExtensionIdExamples(); @@ -27,6 +28,7 @@ export class ExtensionContentController { // Private + /** */ async _prepareEnvironmentInfo() { const {dataset} = document.documentElement; const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); @@ -40,6 +42,7 @@ export class ExtensionContentController { dataset.os = platform.os; } + /** */ _prepareExtensionIdExamples() { const nodes = document.querySelectorAll('.extension-id-example'); let url = ''; @@ -53,8 +56,9 @@ export class ExtensionContentController { } } + /** */ _prepareSpecialUrls() { - const nodes = document.querySelectorAll('[data-special-url]'); + const nodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-special-url]')); if (nodes.length === 0) { return; } let extensionId = ''; @@ -77,16 +81,27 @@ export class ExtensionContentController { } } + /** + * @param {MouseEvent} e + */ _onSpecialUrlLinkClick(e) { switch (e.button) { case 0: case 1: - e.preventDefault(); - this._createTab(e.currentTarget.dataset.specialUrl, true); + { + const element = /** @type {HTMLElement} */ (e.currentTarget); + const {specialUrl} = element.dataset; + if (typeof specialUrl !== 'string') { return; } + e.preventDefault(); + this._createTab(specialUrl, true); + } break; } } + /** + * @param {MouseEvent} e + */ _onSpecialUrlLinkMouseDown(e) { switch (e.button) { case 0: @@ -96,10 +111,17 @@ export class ExtensionContentController { } } + /** + * @param {string} url + * @param {boolean} useOpener + * @returns {Promise<chrome.tabs.Tab>} + */ async _createTab(url, useOpener) { + /** @type {number|undefined} */ let openerTabId; if (useOpener) { try { + /** @type {chrome.tabs.Tab|undefined} */ const tab = await new Promise((resolve, reject) => { chrome.tabs.getCurrent((result) => { const e = chrome.runtime.lastError; @@ -110,7 +132,9 @@ export class ExtensionContentController { } }); }); - openerTabId = tab.id; + if (typeof tab !== 'undefined') { + openerTabId = tab.id; + } } catch (e) { // NOP } diff --git a/ext/js/pages/info-main.js b/ext/js/pages/info-main.js index 7c6bc993..f71d64c3 100644 --- a/ext/js/pages/info-main.js +++ b/ext/js/pages/info-main.js @@ -22,6 +22,10 @@ import {yomitan} from '../yomitan.js'; import {BackupController} from './settings/backup-controller.js'; import {SettingsController} from './settings/settings-controller.js'; +/** + * @param {import('environment').Browser} browser + * @returns {string} + */ function getBrowserDisplayName(browser) { switch (browser) { case 'chrome': return 'Chrome'; @@ -29,10 +33,15 @@ function getBrowserDisplayName(browser) { case 'firefox-mobile': return 'Firefox for Android'; case 'edge': return 'Edge'; case 'edge-legacy': return 'Edge Legacy'; + case 'safari': return 'Safari'; default: return `${browser}`; } } +/** + * @param {import('environment').OperatingSystem} os + * @returns {string} + */ function getOperatingSystemDisplayName(os) { switch (os) { case 'mac': return 'Mac OS'; @@ -60,14 +69,15 @@ function getOperatingSystemDisplayName(os) { const {name, version} = manifest; const {browser, platform: {os}} = await yomitan.api.getEnvironmentInfo(); - const thisVersionLink = document.querySelector('#release-notes-this-version-link'); - thisVersionLink.href = thisVersionLink.dataset.hrefFormat.replace(/\{version\}/g, version); + const thisVersionLink = /** @type {HTMLLinkElement} */ (document.querySelector('#release-notes-this-version-link')); + const {hrefFormat} = thisVersionLink.dataset; + thisVersionLink.href = typeof hrefFormat === 'string' ? hrefFormat.replace(/\{version\}/g, version) : ''; - document.querySelector('#version').textContent = `${name} ${version}`; - document.querySelector('#browser').textContent = getBrowserDisplayName(browser); - document.querySelector('#platform').textContent = getOperatingSystemDisplayName(os); - document.querySelector('#language').textContent = `${language}`; - document.querySelector('#user-agent').textContent = userAgent; + /** @type {HTMLElement} */ (document.querySelector('#version')).textContent = `${name} ${version}`; + /** @type {HTMLElement} */ (document.querySelector('#browser')).textContent = getBrowserDisplayName(browser); + /** @type {HTMLElement} */ (document.querySelector('#platform')).textContent = getOperatingSystemDisplayName(os); + /** @type {HTMLElement} */ (document.querySelector('#language')).textContent = `${language}`; + /** @type {HTMLElement} */ (document.querySelector('#user-agent')).textContent = userAgent; (async () => { let ankiConnectVersion = null; @@ -77,9 +87,9 @@ function getOperatingSystemDisplayName(os) { // NOP } - document.querySelector('#anki-connect-version').textContent = (ankiConnectVersion !== null ? `${ankiConnectVersion}` : 'Unknown'); - document.querySelector('#anki-connect-version-container').hasError = `${ankiConnectVersion === null}`; - document.querySelector('#anki-connect-version-unknown-message').hidden = (ankiConnectVersion !== null); + /** @type {HTMLElement} */ (document.querySelector('#anki-connect-version')).textContent = (ankiConnectVersion !== null ? `${ankiConnectVersion}` : 'Unknown'); + /** @type {HTMLElement} */ (document.querySelector('#anki-connect-version-container')).dataset.hasError = `${ankiConnectVersion === null}`; + /** @type {HTMLElement} */ (document.querySelector('#anki-connect-version-unknown-message')).hidden = (ankiConnectVersion !== null); })(); (async () => { @@ -105,8 +115,8 @@ function getOperatingSystemDisplayName(os) { fragment.appendChild(node); } - document.querySelector('#installed-dictionaries-none').hidden = (dictionaryInfos.length !== 0); - const container = document.querySelector('#installed-dictionaries'); + /** @type {HTMLElement} */ (document.querySelector('#installed-dictionaries-none')).hidden = (dictionaryInfos.length !== 0); + const container = /** @type {HTMLElement} */ (document.querySelector('#installed-dictionaries')); container.textContent = ''; container.appendChild(fragment); })(); diff --git a/ext/js/pages/permissions-main.js b/ext/js/pages/permissions-main.js index ff614880..064e9240 100644 --- a/ext/js/pages/permissions-main.js +++ b/ext/js/pages/permissions-main.js @@ -27,6 +27,9 @@ import {PersistentStorageController} from './settings/persistent-storage-control import {SettingsController} from './settings/settings-controller.js'; import {SettingsDisplayController} from './settings/settings-display-controller.js'; +/** + * @returns {Promise<void>} + */ async function setupEnvironmentInfo() { const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); const {browser, platform} = await yomitan.api.getEnvironmentInfo(); @@ -35,20 +38,39 @@ async function setupEnvironmentInfo() { document.documentElement.dataset.manifestVersion = `${manifestVersion}`; } +/** + * @returns {Promise<boolean>} + */ async function isAllowedIncognitoAccess() { return await new Promise((resolve) => chrome.extension.isAllowedIncognitoAccess(resolve)); } +/** + * @returns {Promise<boolean>} + */ async function isAllowedFileSchemeAccess() { return await new Promise((resolve) => chrome.extension.isAllowedFileSchemeAccess(resolve)); } +/** + * @returns {void} + */ function setupPermissionsToggles() { const manifest = chrome.runtime.getManifest(); - let optionalPermissions = manifest.optional_permissions; - if (!Array.isArray(optionalPermissions)) { optionalPermissions = []; } - optionalPermissions = new Set(optionalPermissions); + const optionalPermissions = manifest.optional_permissions; + /** @type {Set<string>} */ + const optionalPermissionsSet = new Set(optionalPermissions); + if (Array.isArray(optionalPermissions)) { + for (const permission of optionalPermissions) { + optionalPermissionsSet.add(permission); + } + } + /** + * @param {Set<string>} set + * @param {string[]} values + * @returns {boolean} + */ const hasAllPermisions = (set, values) => { for (const value of values) { if (!set.has(value)) { return false; } @@ -56,10 +78,10 @@ function setupPermissionsToggles() { return true; }; - for (const toggle of document.querySelectorAll('.permissions-toggle')) { - let permissions = toggle.dataset.requiredPermissions; - permissions = (typeof permissions === 'string' && permissions.length > 0 ? permissions.split(' ') : []); - toggle.disabled = !hasAllPermisions(optionalPermissions, permissions); + for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-toggle'))) { + const permissions = toggle.dataset.requiredPermissions; + const permissionsArray = (typeof permissions === 'string' && permissions.length > 0 ? permissions.split(' ') : []); + toggle.disabled = !hasAllPermisions(optionalPermissionsSet, permissionsArray); } } @@ -77,9 +99,10 @@ function setupPermissionsToggles() { setupEnvironmentInfo(); + /** @type {[HTMLInputElement, HTMLInputElement]} */ const permissionsCheckboxes = [ - document.querySelector('#permission-checkbox-allow-in-private-windows'), - document.querySelector('#permission-checkbox-allow-file-url-access') + /** @type {HTMLInputElement} */ (document.querySelector('#permission-checkbox-allow-in-private-windows')), + /** @type {HTMLInputElement} */ (document.querySelector('#permission-checkbox-allow-file-url-access')) ]; const permissions = await Promise.all([ diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js index 8164b8f6..05bfdadc 100644 --- a/ext/js/pages/settings/anki-controller.js +++ b/ext/js/pages/settings/anki-controller.js @@ -18,15 +18,22 @@ import {AnkiConnect} from '../../comm/anki-connect.js'; import {EventListenerCollection, log} from '../../core.js'; +import {ExtensionError} from '../../core/extension-error.js'; import {AnkiUtil} from '../../data/anki-util.js'; import {SelectorObserver} from '../../dom/selector-observer.js'; import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js'; import {yomitan} from '../../yomitan.js'; export class AnkiController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {AnkiConnect} */ this._ankiConnect = new AnkiConnect(); + /** @type {SelectorObserver<AnkiCardController>} */ this._selectorObserver = new SelectorObserver({ selector: '.anki-card', ignoreSelector: null, @@ -34,52 +41,74 @@ export class AnkiController { onRemoved: this._removeCardController.bind(this), isStale: this._isCardControllerStale.bind(this) }); + /** @type {Intl.Collator} */ this._stringComparer = new Intl.Collator(); // Locale does not matter + /** @type {?Promise<import('anki-controller').AnkiData>} */ this._getAnkiDataPromise = null; + /** @type {?HTMLElement} */ this._ankiErrorContainer = null; + /** @type {?HTMLElement} */ this._ankiErrorMessageNode = null; + /** @type {string} */ this._ankiErrorMessageNodeDefaultContent = ''; + /** @type {?HTMLElement} */ this._ankiErrorMessageDetailsNode = null; + /** @type {?HTMLElement} */ this._ankiErrorMessageDetailsContainer = null; + /** @type {?HTMLElement} */ this._ankiErrorMessageDetailsToggle = null; + /** @type {?HTMLElement} */ this._ankiErrorInvalidResponseInfo = null; + /** @type {?HTMLElement} */ this._ankiCardPrimary = null; + /** @type {?Error} */ this._ankiError = null; + /** @type {?import('core').TokenObject} */ this._validateFieldsToken = null; } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._settingsController; } + /** */ async prepare() { - this._ankiErrorContainer = document.querySelector('#anki-error'); - this._ankiErrorMessageNode = document.querySelector('#anki-error-message'); - this._ankiErrorMessageNodeDefaultContent = this._ankiErrorMessageNode.textContent; - this._ankiErrorMessageDetailsNode = document.querySelector('#anki-error-message-details'); - this._ankiErrorMessageDetailsContainer = document.querySelector('#anki-error-message-details-container'); - this._ankiErrorMessageDetailsToggle = document.querySelector('#anki-error-message-details-toggle'); - this._ankiErrorInvalidResponseInfo = document.querySelector('#anki-error-invalid-response-info'); - this._ankiEnableCheckbox = document.querySelector('[data-setting="anki.enable"]'); - this._ankiCardPrimary = document.querySelector('#anki-card-primary'); - const ankiApiKeyInput = document.querySelector('#anki-api-key-input'); - const ankiCardPrimaryTypeRadios = document.querySelectorAll('input[type=radio][name=anki-card-primary-type]'); + this._ankiErrorContainer = /** @type {HTMLElement} */ (document.querySelector('#anki-error')); + this._ankiErrorMessageNode = /** @type {HTMLElement} */ (document.querySelector('#anki-error-message')); + const ankiErrorMessageNodeDefaultContent = this._ankiErrorMessageNode.textContent; + this._ankiErrorMessageNodeDefaultContent = typeof ankiErrorMessageNodeDefaultContent === 'string' ? ankiErrorMessageNodeDefaultContent : ''; + this._ankiErrorMessageDetailsNode = /** @type {HTMLElement} */ (document.querySelector('#anki-error-message-details')); + this._ankiErrorMessageDetailsContainer = /** @type {HTMLElement} */ (document.querySelector('#anki-error-message-details-container')); + this._ankiErrorMessageDetailsToggle = /** @type {HTMLElement} */ (document.querySelector('#anki-error-message-details-toggle')); + this._ankiErrorInvalidResponseInfo = /** @type {HTMLElement} */ (document.querySelector('#anki-error-invalid-response-info')); + this._ankiEnableCheckbox = /** @type {?HTMLInputElement} */ (document.querySelector('[data-setting="anki.enable"]')); + this._ankiCardPrimary = /** @type {HTMLElement} */ (document.querySelector('#anki-card-primary')); + const ankiApiKeyInput = /** @type {HTMLElement} */ (document.querySelector('#anki-api-key-input')); + const ankiCardPrimaryTypeRadios = /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('input[type=radio][name=anki-card-primary-type]')); + const ankiErrorLog = /** @type {HTMLElement} */ (document.querySelector('#anki-error-log')); this._setupFieldMenus(); this._ankiErrorMessageDetailsToggle.addEventListener('click', this._onAnkiErrorMessageDetailsToggleClick.bind(this), false); - if (this._ankiEnableCheckbox !== null) { this._ankiEnableCheckbox.addEventListener('settingChanged', this._onAnkiEnableChanged.bind(this), false); } + if (this._ankiEnableCheckbox !== null) { + this._ankiEnableCheckbox.addEventListener( + /** @type {string} */ ('settingChanged'), + /** @type {EventListener} */ (this._onAnkiEnableChanged.bind(this)), + false + ); + } for (const input of ankiCardPrimaryTypeRadios) { input.addEventListener('change', this._onAnkiCardPrimaryTypeRadioChange.bind(this), false); } - const testAnkiNoteViewerButtons = document.querySelectorAll('.test-anki-note-viewer-button'); + const testAnkiNoteViewerButtons = /** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('.test-anki-note-viewer-button')); const onTestAnkiNoteViewerButtonClick = this._onTestAnkiNoteViewerButtonClick.bind(this); for (const button of testAnkiNoteViewerButtons) { button.addEventListener('click', onTestAnkiNoteViewerButtonClick, false); } - document.querySelector('#anki-error-log').addEventListener('click', this._onAnkiErrorLogLinkClick.bind(this)); + ankiErrorLog.addEventListener('click', this._onAnkiErrorLogLinkClick.bind(this)); ankiApiKeyInput.addEventListener('focus', this._onApiKeyInputFocus.bind(this)); ankiApiKeyInput.addEventListener('blur', this._onApiKeyInputBlur.bind(this)); @@ -94,6 +123,10 @@ export class AnkiController { this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); } + /** + * @param {string} type + * @returns {string[]} + */ getFieldMarkers(type) { switch (type) { case 'terms': @@ -154,6 +187,9 @@ export class AnkiController { } } + /** + * @returns {Promise<import('anki-controller').AnkiData>} + */ async getAnkiData() { let promise = this._getAnkiDataPromise; if (promise === null) { @@ -164,23 +200,37 @@ export class AnkiController { return promise; } + /** + * @param {string} model + * @returns {Promise<string[]>} + */ async getModelFieldNames(model) { return await this._ankiConnect.getModelFieldNames(model); } + /** + * @param {string} fieldValue + * @returns {string[]} + */ getRequiredPermissions(fieldValue) { return this._settingsController.permissionsUtil.getRequiredPermissionsForAnkiFieldValue(fieldValue); } // Private + /** */ async _updateOptions() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ async _onOptionsChanged({options: {anki}}) { - let {apiKey} = anki; + /** @type {?string} */ + let apiKey = anki.apiKey; if (apiKey === '') { apiKey = null; } this._ankiConnect.server = anki.server; this._ankiConnect.enabled = anki.enable; @@ -190,44 +240,73 @@ export class AnkiController { this._selectorObserver.observe(document.documentElement, true); } + /** */ _onAnkiErrorMessageDetailsToggleClick() { - const node = this._ankiErrorMessageDetailsContainer; + const node = /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsContainer); node.hidden = !node.hidden; } + /** + * @param {import('dom-data-binder').SettingChangedEvent} event + */ _onAnkiEnableChanged({detail: {value}}) { if (this._ankiConnect.server === null) { return; } - this._ankiConnect.enabled = value; + this._ankiConnect.enabled = typeof value === 'boolean' && value; for (const cardController of this._selectorObserver.datas()) { cardController.updateAnkiState(); } } + /** + * @param {Event} e + */ _onAnkiCardPrimaryTypeRadioChange(e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); if (!node.checked) { return; } - - this._setAnkiCardPrimaryType(node.dataset.value, node.dataset.ankiCardMenu); + const {value, ankiCardMenu} = node.dataset; + if (typeof value !== 'string') { return; } + this._setAnkiCardPrimaryType(value, ankiCardMenu); } + /** */ _onAnkiErrorLogLinkClick() { if (this._ankiError === null) { return; } console.log({error: this._ankiError}); } + /** + * @param {MouseEvent} e + */ _onTestAnkiNoteViewerButtonClick(e) { - this._testAnkiNoteViewerSafe(e.currentTarget.dataset.mode); + const element = /** @type {HTMLElement} */ (e.currentTarget); + const {mode} = element.dataset; + if (typeof mode !== 'string') { return; } + const mode2 = this._normalizeAnkiNoteGuiMode(mode); + if (mode2 === null) { return; } + this._testAnkiNoteViewerSafe(mode2); } + /** + * @param {Event} e + */ _onApiKeyInputFocus(e) { - e.currentTarget.type = 'text'; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + element.type = 'text'; } + /** + * @param {Event} e + */ _onApiKeyInputBlur(e) { - e.currentTarget.type = 'password'; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + element.type = 'password'; } + /** + * @param {string} ankiCardType + * @param {string} [ankiCardMenu] + */ _setAnkiCardPrimaryType(ankiCardType, ankiCardMenu) { if (this._ankiCardPrimary === null) { return; } this._ankiCardPrimary.dataset.ankiCardType = ankiCardType; @@ -238,28 +317,43 @@ export class AnkiController { } } + /** + * @param {Element} node + * @returns {AnkiCardController} + */ _createCardController(node) { - const cardController = new AnkiCardController(this._settingsController, this, node); + const cardController = new AnkiCardController(this._settingsController, this, /** @type {HTMLElement} */ (node)); cardController.prepare(); return cardController; } - _removeCardController(node, cardController) { + /** + * @param {Element} _node + * @param {AnkiCardController} cardController + */ + _removeCardController(_node, cardController) { cardController.cleanup(); } - _isCardControllerStale(node, cardController) { + /** + * @param {Element} _node + * @param {AnkiCardController} cardController + * @returns {boolean} + */ + _isCardControllerStale(_node, cardController) { return cardController.isStale(); } + /** */ _setupFieldMenus() { + /** @type {[types: string[], selector: string][]} */ const fieldMenuTargets = [ [['terms'], '#anki-card-terms-field-menu-template'], [['kanji'], '#anki-card-kanji-field-menu-template'], [['terms', 'kanji'], '#anki-card-all-field-menu-template'] ]; for (const [types, selector] of fieldMenuTargets) { - const element = document.querySelector(selector); + const element = /** @type {HTMLTemplateElement} */ (document.querySelector(selector)); if (element === null) { continue; } let markers = []; @@ -284,6 +378,9 @@ export class AnkiController { } } + /** + * @returns {Promise<import('anki-controller').AnkiData>} + */ async _getAnkiData() { this._setAnkiStatusChanging(); const [ @@ -305,85 +402,108 @@ export class AnkiController { return {deckNames, modelNames}; } + /** + * @returns {Promise<[deckNames: string[], error: ?Error]>} + */ async _getDeckNames() { try { const result = await this._ankiConnect.getDeckNames(); this._sortStringArray(result); return [result, null]; } catch (e) { - return [[], e]; + return [[], e instanceof Error ? e : new Error(`${e}`)]; } } + /** + * @returns {Promise<[modelNames: string[], error: ?Error]>} + */ async _getModelNames() { try { const result = await this._ankiConnect.getModelNames(); this._sortStringArray(result); return [result, null]; } catch (e) { - return [[], e]; + return [[], e instanceof Error ? e : new Error(`${e}`)]; } } + /** */ _setAnkiStatusChanging() { - this._ankiErrorMessageNode.textContent = this._ankiErrorMessageNodeDefaultContent; - this._ankiErrorMessageNode.classList.remove('danger-text'); + const ankiErrorMessageNode = /** @type {HTMLElement} */ (this._ankiErrorMessageNode); + ankiErrorMessageNode.textContent = this._ankiErrorMessageNodeDefaultContent; + ankiErrorMessageNode.classList.remove('danger-text'); } + /** */ _hideAnkiError() { + const ankiErrorMessageNode = /** @type {HTMLElement} */ (this._ankiErrorMessageNode); if (this._ankiErrorContainer !== null) { this._ankiErrorContainer.hidden = true; } - this._ankiErrorMessageDetailsContainer.hidden = true; - this._ankiErrorMessageDetailsToggle.hidden = true; - this._ankiErrorInvalidResponseInfo.hidden = true; - this._ankiErrorMessageNode.textContent = (this._ankiConnect.enabled ? 'Connected' : 'Not enabled'); - this._ankiErrorMessageNode.classList.remove('danger-text'); - this._ankiErrorMessageDetailsNode.textContent = ''; + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsContainer).hidden = true; + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsToggle).hidden = true; + /** @type {HTMLElement} */ (this._ankiErrorInvalidResponseInfo).hidden = true; + ankiErrorMessageNode.textContent = (this._ankiConnect.enabled ? 'Connected' : 'Not enabled'); + ankiErrorMessageNode.classList.remove('danger-text'); + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsNode).textContent = ''; this._ankiError = null; } + /** + * @param {Error} error + */ _showAnkiError(error) { + const ankiErrorMessageNode = /** @type {HTMLElement} */ (this._ankiErrorMessageNode); this._ankiError = error; let errorString = typeof error === 'object' && error !== null ? error.message : null; if (!errorString) { errorString = `${error}`; } if (!/[.!?]$/.test(errorString)) { errorString += '.'; } - this._ankiErrorMessageNode.textContent = errorString; - this._ankiErrorMessageNode.classList.add('danger-text'); + ankiErrorMessageNode.textContent = errorString; + ankiErrorMessageNode.classList.add('danger-text'); - const data = error.data; + const data = error instanceof ExtensionError ? error.data : void 0; let details = ''; if (typeof data !== 'undefined') { details += `${JSON.stringify(data, null, 4)}\n\n`; } details += `${error.stack}`.trimRight(); - this._ankiErrorMessageDetailsNode.textContent = details; + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsNode).textContent = details; if (this._ankiErrorContainer !== null) { this._ankiErrorContainer.hidden = false; } - this._ankiErrorMessageDetailsContainer.hidden = true; - this._ankiErrorInvalidResponseInfo.hidden = (errorString.indexOf('Invalid response') < 0); - this._ankiErrorMessageDetailsToggle.hidden = false; + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsContainer).hidden = true; + /** @type {HTMLElement} */ (this._ankiErrorInvalidResponseInfo).hidden = (errorString.indexOf('Invalid response') < 0); + /** @type {HTMLElement} */ (this._ankiErrorMessageDetailsToggle).hidden = false; } + /** + * @param {string[]} array + */ _sortStringArray(array) { const stringComparer = this._stringComparer; array.sort((a, b) => stringComparer.compare(a, b)); } + /** + * @param {import('settings').AnkiNoteGuiMode} mode + */ async _testAnkiNoteViewerSafe(mode) { this._setAnkiNoteViewerStatus(false, null); try { await this._testAnkiNoteViewer(mode); } catch (e) { - this._setAnkiNoteViewerStatus(true, e); + this._setAnkiNoteViewerStatus(true, e instanceof Error ? e : new Error(`${e}`)); return; } this._setAnkiNoteViewerStatus(true, null); } + /** + * @param {import('settings').AnkiNoteGuiMode} mode + */ async _testAnkiNoteViewer(mode) { const queries = [ '"よむ" deck:current', @@ -408,8 +528,12 @@ export class AnkiController { await yomitan.api.noteView(noteId, mode, false); } + /** + * @param {boolean} visible + * @param {?Error} error + */ _setAnkiNoteViewerStatus(visible, error) { - const node = document.querySelector('#test-anki-note-viewer-results'); + const node = /** @type {HTMLElement} */ (document.querySelector('#test-anki-note-viewer-results')); if (visible) { const success = (error === null); node.textContent = success ? 'Success!' : error.message; @@ -420,26 +544,61 @@ export class AnkiController { } node.hidden = !visible; } + + /** + * @param {string} value + * @returns {?import('settings').AnkiNoteGuiMode} + */ + _normalizeAnkiNoteGuiMode(value) { + switch (value) { + case 'browse': + case 'edit': + return value; + default: + return null; + } + } } class AnkiCardController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {AnkiController} ankiController + * @param {HTMLElement} node + */ constructor(settingsController, ankiController, node) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {AnkiController} */ this._ankiController = ankiController; + /** @type {HTMLElement} */ this._node = node; - this._cardType = node.dataset.ankiCardType; + const {ankiCardType} = node.dataset; + /** @type {string} */ + this._cardType = typeof ankiCardType === 'string' ? ankiCardType : 'terms'; + /** @type {string|undefined} */ this._cardMenu = node.dataset.ankiCardMenu; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {EventListenerCollection} */ this._fieldEventListeners = new EventListenerCollection(); - this._fields = null; + /** @type {import('settings').AnkiNoteFields} */ + this._fields = {}; + /** @type {?string} */ this._modelChangingTo = null; + /** @type {?Element} */ this._ankiCardFieldsContainer = null; + /** @type {boolean} */ this._cleaned = false; + /** @type {import('anki-controller').FieldEntry[]} */ this._fieldEntries = []; + /** @type {AnkiCardSelectController} */ this._deckController = new AnkiCardSelectController(); + /** @type {AnkiCardSelectController} */ this._modelController = new AnkiCardSelectController(); } + /** */ async prepare() { const options = await this._settingsController.getOptions(); const ankiOptions = options.anki; @@ -448,8 +607,8 @@ class AnkiCardController { const cardOptions = this._getCardOptions(ankiOptions, this._cardType); if (cardOptions === null) { return; } const {deck, model, fields} = cardOptions; - this._deckController.prepare(this._node.querySelector('.anki-card-deck'), deck); - this._modelController.prepare(this._node.querySelector('.anki-card-model'), model); + this._deckController.prepare(/** @type {HTMLSelectElement} */ (this._node.querySelector('.anki-card-deck')), deck); + this._modelController.prepare(/** @type {HTMLSelectElement} */ (this._node.querySelector('.anki-card-model')), model); this._fields = fields; this._ankiCardFieldsContainer = this._node.querySelector('.anki-card-fields'); @@ -463,12 +622,14 @@ class AnkiCardController { await this.updateAnkiState(); } + /** */ cleanup() { this._cleaned = true; this._fieldEntries = []; this._eventListeners.removeAllEventListeners(); } + /** */ async updateAnkiState() { if (this._fields === null) { return; } const {deckNames, modelNames} = await this._ankiController.getAnkiData(); @@ -477,41 +638,70 @@ class AnkiCardController { this._modelController.setOptionValues(modelNames); } + /** + * @returns {boolean} + */ isStale() { return (this._cardType !== this._node.dataset.ankiCardType); } // Private + /** + * @param {Event} e + */ _onCardDeckChange(e) { - this._setDeck(e.currentTarget.value); + const node = /** @type {HTMLSelectElement} */ (e.currentTarget); + this._setDeck(node.value); } + /** + * @param {Event} e + */ _onCardModelChange(e) { - this._setModel(e.currentTarget.value); + const node = /** @type {HTMLSelectElement} */ (e.currentTarget); + this._setModel(node.value); } + /** + * @param {number} index + * @param {Event} e + */ _onFieldChange(index, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); this._validateFieldPermissions(node, index, true); this._validateField(node, index); } + /** + * @param {number} index + * @param {Event} e + */ _onFieldInput(index, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); this._validateField(node, index); } + /** + * @param {number} index + * @param {import('dom-data-binder').SettingChangedEvent} e + */ _onFieldSettingChanged(index, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); this._validateFieldPermissions(node, index, false); } - _onFieldMenuOpen({currentTarget: button, detail: {menu}}) { - let {index, fieldName} = button.dataset; - index = Number.parseInt(index, 10); - - const defaultValue = this._getDefaultFieldValue(fieldName, index, this._cardType, null); + /** + * @param {import('popup-menu').MenuOpenEvent} event + */ + _onFieldMenuOpen(event) { + const button = /** @type {HTMLElement} */ (event.currentTarget); + const {menu} = event.detail; + const {index, fieldName} = button.dataset; + const indexNumber = typeof index === 'string' ? Number.parseInt(index, 10) : 0; + if (typeof fieldName !== 'string') { return; } + + const defaultValue = this._getDefaultFieldValue(fieldName, indexNumber, this._cardType, null); if (defaultValue === '') { return; } const match = /^\{([\w\W]+)\}$/.exec(defaultValue); @@ -524,14 +714,28 @@ class AnkiCardController { item.classList.add('popup-menu-item-bold'); } - _onFieldMenuClose({currentTarget: button, detail: {action, item}}) { + /** + * @param {import('popup-menu').MenuCloseEvent} event + */ + _onFieldMenuClose(event) { + const button = /** @type {HTMLElement} */ (event.currentTarget); + const {action, item} = event.detail; switch (action) { case 'setFieldMarker': - this._setFieldMarker(button, item.dataset.marker); + if (item !== null) { + const {marker} = item.dataset; + if (typeof marker === 'string') { + this._setFieldMarker(button, marker); + } + } break; } } + /** + * @param {HTMLInputElement} node + * @param {number} index + */ _validateField(node, index) { let valid = (node.dataset.hasPermissions !== 'false'); if (valid && index === 0 && !AnkiUtil.stringContainsAnyFieldMarker(node.value)) { @@ -540,12 +744,23 @@ class AnkiCardController { node.dataset.invalid = `${!valid}`; } + /** + * @param {Element} element + * @param {string} marker + */ _setFieldMarker(element, marker) { - const input = element.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value'); + const container = element.closest('.anki-card-field-value-container'); + if (container === null) { return; } + const input = /** @type {HTMLInputElement} */ (container.querySelector('.anki-card-field-value')); input.value = `{${marker}}`; input.dispatchEvent(new Event('change')); } + /** + * @param {import('settings').AnkiOptions} ankiOptions + * @param {string} cardType + * @returns {?import('settings').AnkiNoteOptions} + */ _getCardOptions(ankiOptions, cardType) { switch (cardType) { case 'terms': return ankiOptions.terms; @@ -554,6 +769,7 @@ class AnkiCardController { } } + /** */ _setupFields() { this._fieldEventListeners.removeAllEventListeners(); @@ -563,15 +779,15 @@ class AnkiCardController { for (const [fieldName, fieldValue] of Object.entries(this._fields)) { const content = this._settingsController.instantiateTemplateFragment('anki-card-field'); - const fieldNameContainerNode = content.querySelector('.anki-card-field-name-container'); + const fieldNameContainerNode = /** @type {HTMLElement} */ (content.querySelector('.anki-card-field-name-container')); fieldNameContainerNode.dataset.index = `${index}`; - const fieldNameNode = content.querySelector('.anki-card-field-name'); + const fieldNameNode = /** @type {HTMLElement} */ (content.querySelector('.anki-card-field-name')); fieldNameNode.textContent = fieldName; - const valueContainer = content.querySelector('.anki-card-field-value-container'); + const valueContainer = /** @type {HTMLElement} */ (content.querySelector('.anki-card-field-value-container')); valueContainer.dataset.index = `${index}`; - const inputField = content.querySelector('.anki-card-field-value'); + const inputField = /** @type {HTMLInputElement} */ (content.querySelector('.anki-card-field-value')); inputField.value = fieldValue; inputField.dataset.setting = ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields', fieldName]); this._validateFieldPermissions(inputField, index, false); @@ -581,7 +797,7 @@ class AnkiCardController { this._fieldEventListeners.addEventListener(inputField, 'settingChanged', this._onFieldSettingChanged.bind(this, index), false); this._validateField(inputField, index); - const menuButton = content.querySelector('.anki-card-field-value-menu-button'); + const menuButton = /** @type {?HTMLElement} */ (content.querySelector('.anki-card-field-value-menu-button')); if (menuButton !== null) { if (typeof this._cardMenu !== 'undefined') { menuButton.dataset.menu = this._cardMenu; @@ -602,15 +818,18 @@ class AnkiCardController { const ELEMENT_NODE = Node.ELEMENT_NODE; const container = this._ankiCardFieldsContainer; - for (const node of [...container.childNodes]) { - if (node.nodeType === ELEMENT_NODE && node.dataset.persistent === 'true') { continue; } - container.removeChild(node); + if (container !== null) { + for (const node of [...container.childNodes]) { + if (node.nodeType === ELEMENT_NODE && node instanceof HTMLElement && node.dataset.persistent === 'true') { continue; } + container.removeChild(node); + } + container.appendChild(totalFragment); } - container.appendChild(totalFragment); this._validateFields(); } + /** */ async _validateFields() { const token = {}; this._validateFieldsToken = token; @@ -633,6 +852,9 @@ class AnkiCardController { } } + /** + * @param {string} value + */ async _setDeck(value) { if (this._deckController.value === value) { return; } this._deckController.value = value; @@ -644,6 +866,9 @@ class AnkiCardController { }]); } + /** + * @param {string} value + */ async _setModel(value) { const select = this._modelController.select; if (this._modelChangingTo !== null) { @@ -671,12 +896,14 @@ class AnkiCardController { const cardOptions = this._getCardOptions(options.anki, cardType); const oldFields = cardOptions !== null ? cardOptions.fields : null; + /** @type {import('settings').AnkiNoteFields} */ const fields = {}; for (let i = 0, ii = fieldNames.length; i < ii; ++i) { const fieldName = fieldNames[i]; fields[fieldName] = this._getDefaultFieldValue(fieldName, i, cardType, oldFields); } + /** @type {import('settings-modifications').Modification[]} */ const targets = [ { action: 'set', @@ -698,6 +925,9 @@ class AnkiCardController { this._setupFields(); } + /** + * @param {string[]} permissions + */ async _requestPermissions(permissions) { try { await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true); @@ -706,6 +936,11 @@ class AnkiCardController { } } + /** + * @param {HTMLInputElement} node + * @param {number} index + * @param {boolean} request + */ async _validateFieldPermissions(node, index, request) { const fieldValue = node.value; const permissions = this._ankiController.getRequiredPermissions(fieldValue); @@ -725,16 +960,19 @@ class AnkiCardController { this._validateField(node, index); } + /** + * @param {import('settings-controller').PermissionsChangedEvent} details + */ _onPermissionsChanged({permissions: {permissions}}) { const permissionsSet = new Set(permissions); for (let i = 0, ii = this._fieldEntries.length; i < ii; ++i) { const {inputField} = this._fieldEntries[i]; - let {requiredPermission} = inputField.dataset; + const {requiredPermission} = inputField.dataset; if (typeof requiredPermission !== 'string') { continue; } - requiredPermission = (requiredPermission.length === 0 ? [] : requiredPermission.split(' ')); + const requiredPermissionArray = (requiredPermission.length === 0 ? [] : requiredPermission.split(' ')); let hasPermissions = true; - for (const permission of requiredPermission) { + for (const permission of requiredPermissionArray) { if (!permissionsSet.has(permission)) { hasPermissions = false; break; @@ -746,6 +984,13 @@ class AnkiCardController { } } + /** + * @param {string} fieldName + * @param {number} index + * @param {string} cardType + * @param {?import('settings').AnkiNoteFields} oldFields + * @returns {string} + */ _getDefaultFieldValue(fieldName, index, cardType, oldFields) { if ( typeof oldFields === 'object' && @@ -783,9 +1028,9 @@ class AnkiCardController { pattern += name.replace(hyphenPattern, '[-_ ]*'); } pattern += ')$'; - pattern = new RegExp(pattern, 'i'); + const patternRegExp = new RegExp(pattern, 'i'); - if (pattern.test(fieldName)) { + if (patternRegExp.test(fieldName)) { return `{${marker}}`; } } @@ -796,14 +1041,21 @@ class AnkiCardController { class AnkiCardSelectController { constructor() { + /** @type {?string} */ this._value = null; + /** @type {?HTMLSelectElement} */ this._select = null; - this._optionValues = null; + /** @type {string[]} */ + this._optionValues = []; + /** @type {boolean} */ this._hasExtraOption = false; + /** @type {boolean} */ this._selectNeedsUpdate = false; } + /** @type {string} */ get value() { + if (this._value === null) { throw new Error('Invalid value'); } return this._value; } @@ -812,16 +1064,25 @@ class AnkiCardSelectController { this._updateSelect(); } + /** @type {HTMLSelectElement} */ get select() { + if (this._select === null) { throw new Error('Invalid value'); } return this._select; } + /** + * @param {HTMLSelectElement} select + * @param {string} value + */ prepare(select, value) { this._select = select; this._value = value; this._updateSelect(); } + /** + * @param {string[]} optionValues + */ setOptionValues(optionValues) { this._optionValues = optionValues; this._selectNeedsUpdate = true; @@ -830,8 +1091,11 @@ class AnkiCardSelectController { // Private + /** */ _updateSelect() { + const select = this._select; const value = this._value; + if (select === null || value === null) { return; } let optionValues = this._optionValues; const hasOptionValues = Array.isArray(optionValues) && optionValues.length > 0; @@ -844,7 +1108,6 @@ class AnkiCardSelectController { optionValues = [...optionValues, value]; } - const select = this._select; if (this._selectNeedsUpdate || hasExtraOption !== this._hasExtraOption) { this._setSelectOptions(select, optionValues); select.value = value; @@ -859,6 +1122,10 @@ class AnkiCardSelectController { } } + /** + * @param {HTMLSelectElement} select + * @param {string[]} optionValues + */ _setSelectOptions(select, optionValues) { const fragment = document.createDocumentFragment(); for (const optionValue of optionValues) { diff --git a/ext/js/pages/settings/anki-templates-controller.js b/ext/js/pages/settings/anki-templates-controller.js index ac8a0205..89848ef3 100644 --- a/ext/js/pages/settings/anki-templates-controller.js +++ b/ext/js/pages/settings/anki-templates-controller.js @@ -16,39 +16,57 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {isObject} from '../../core.js'; +import {ExtensionError} from '../../core/extension-error.js'; import {AnkiNoteBuilder} from '../../data/anki-note-builder.js'; import {JapaneseUtil} from '../../language/sandbox/japanese-util.js'; import {yomitan} from '../../yomitan.js'; export class AnkiTemplatesController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + * @param {import('./anki-controller.js').AnkiController} ankiController + */ constructor(settingsController, modalController, ankiController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {import('./anki-controller.js').AnkiController} */ this._ankiController = ankiController; + /** @type {?import('dictionary').TermDictionaryEntry} */ this._cachedDictionaryEntryValue = null; + /** @type {?string} */ this._cachedDictionaryEntryText = null; + /** @type {?string} */ this._defaultFieldTemplates = null; + /** @type {?HTMLTextAreaElement} */ this._fieldTemplatesTextarea = null; + /** @type {?HTMLElement} */ this._compileResultInfo = null; + /** @type {?HTMLInputElement} */ this._renderFieldInput = null; + /** @type {?HTMLElement} */ this._renderResult = null; + /** @type {?import('./modal.js').Modal} */ this._fieldTemplateResetModal = null; + /** @type {AnkiNoteBuilder} */ this._ankiNoteBuilder = new AnkiNoteBuilder({japaneseUtil: new JapaneseUtil(null)}); } + /** */ async prepare() { this._defaultFieldTemplates = await yomitan.api.getDefaultAnkiFieldTemplates(); - this._fieldTemplatesTextarea = document.querySelector('#anki-card-templates-textarea'); - this._compileResultInfo = document.querySelector('#anki-card-templates-compile-result'); - this._renderFieldInput = document.querySelector('#anki-card-templates-test-field-input'); - this._renderTextInput = document.querySelector('#anki-card-templates-test-text-input'); - this._renderResult = document.querySelector('#anki-card-templates-render-result'); - const menuButton = document.querySelector('#anki-card-templates-test-field-menu-button'); - const testRenderButton = document.querySelector('#anki-card-templates-test-render-button'); - const resetButton = document.querySelector('#anki-card-templates-reset-button'); - const resetConfirmButton = document.querySelector('#anki-card-templates-reset-button-confirm'); + this._fieldTemplatesTextarea = /** @type {HTMLTextAreaElement} */ (document.querySelector('#anki-card-templates-textarea')); + this._compileResultInfo = /** @type {HTMLElement} */ (document.querySelector('#anki-card-templates-compile-result')); + this._renderFieldInput = /** @type {HTMLInputElement} */ (document.querySelector('#anki-card-templates-test-field-input')); + this._renderTextInput = /** @type {HTMLInputElement} */ (document.querySelector('#anki-card-templates-test-text-input')); + this._renderResult = /** @type {HTMLElement} */ (document.querySelector('#anki-card-templates-render-result')); + const menuButton = /** @type {HTMLButtonElement} */ (document.querySelector('#anki-card-templates-test-field-menu-button')); + const testRenderButton = /** @type {HTMLButtonElement} */ (document.querySelector('#anki-card-templates-test-render-button')); + const resetButton = /** @type {HTMLButtonElement} */ (document.querySelector('#anki-card-templates-reset-button')); + const resetConfirmButton = /** @type {HTMLButtonElement} */ (document.querySelector('#anki-card-templates-reset-button-confirm')); this._fieldTemplateResetModal = this._modalController.getModal('anki-card-templates-reset'); this._fieldTemplatesTextarea.addEventListener('change', this._onChanged.bind(this), false); @@ -56,44 +74,71 @@ export class AnkiTemplatesController { resetButton.addEventListener('click', this._onReset.bind(this), false); resetConfirmButton.addEventListener('click', this._onResetConfirm.bind(this), false); if (menuButton !== null) { - menuButton.addEventListener('menuClose', this._onFieldMenuClose.bind(this), false); + menuButton.addEventListener( + /** @type {string} */ ('menuClose'), + /** @type {EventListener} */ (this._onFieldMenuClose.bind(this)), + false + ); } this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { let templates = options.anki.fieldTemplates; - if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } - this._fieldTemplatesTextarea.value = templates; + if (typeof templates !== 'string') { + templates = this._defaultFieldTemplates; + if (typeof templates !== 'string') { templates = ''; } + } + /** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).value = templates; this._onValidateCompile(); } + /** + * @param {MouseEvent} e + */ _onReset(e) { e.preventDefault(); - this._fieldTemplateResetModal.setVisible(true); + if (this._fieldTemplateResetModal !== null) { + this._fieldTemplateResetModal.setVisible(true); + } } + /** + * @param {MouseEvent} e + */ _onResetConfirm(e) { e.preventDefault(); - this._fieldTemplateResetModal.setVisible(false); + if (this._fieldTemplateResetModal !== null) { + this._fieldTemplateResetModal.setVisible(false); + } const value = this._defaultFieldTemplates; - this._fieldTemplatesTextarea.value = value; - this._fieldTemplatesTextarea.dispatchEvent(new Event('change')); + const textarea = /** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea); + textarea.value = typeof value === 'string' ? value : ''; + textarea.dispatchEvent(new Event('change')); } + /** + * @param {Event} e + */ async _onChanged(e) { // Get value - let templates = e.currentTarget.value; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + /** @type {?string} */ + let templates = element.value; if (templates === this._defaultFieldTemplates) { // Default templates = null; @@ -106,34 +151,55 @@ export class AnkiTemplatesController { this._onValidateCompile(); } + /** */ _onValidateCompile() { + if (this._compileResultInfo === null) { return; } this._validate(this._compileResultInfo, '{expression}', 'term-kanji', false, true); } + /** + * @param {Event} e + */ _onRender(e) { e.preventDefault(); - const field = this._renderFieldInput.value; - const infoNode = this._renderResult; + const field = /** @type {HTMLInputElement} */ (this._renderFieldInput).value; + const infoNode = /** @type {HTMLElement} */ (this._renderResult); infoNode.hidden = true; this._cachedDictionaryEntryText = null; this._validate(infoNode, field, 'term-kanji', true, false); } - _onFieldMenuClose({currentTarget: button, detail: {action, item}}) { + /** + * @param {import('popup-menu').MenuCloseEvent} event + */ + _onFieldMenuClose({detail: {action, item}}) { switch (action) { case 'setFieldMarker': - this._setFieldMarker(button, item.dataset.marker); + { + const {marker} = /** @type {HTMLElement} */ (item).dataset; + if (typeof marker === 'string') { + this._setFieldMarker(marker); + } + } break; } } - _setFieldMarker(element, marker) { - const input = this._renderFieldInput; + /** + * @param {string} marker + */ + _setFieldMarker(marker) { + const input = /** @type {HTMLInputElement} */ (this._renderFieldInput); input.value = `{${marker}}`; input.dispatchEvent(new Event('change')); } + /** + * @param {string} text + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<?{dictionaryEntry: import('dictionary').TermDictionaryEntry, text: string}>} + */ async _getDictionaryEntry(text, optionsContext) { if (this._cachedDictionaryEntryText !== text) { const {dictionaryEntries} = await yomitan.api.termsFind(text, {}, optionsContext); @@ -143,19 +209,28 @@ export class AnkiTemplatesController { this._cachedDictionaryEntryText = text; } return { - dictionaryEntry: this._cachedDictionaryEntryValue, + dictionaryEntry: /** @type {import('dictionary').TermDictionaryEntry} */ (this._cachedDictionaryEntryValue), text: this._cachedDictionaryEntryText }; } + /** + * @param {HTMLElement} infoNode + * @param {string} field + * @param {import('anki-templates-internal').CreateModeNoTest} mode + * @param {boolean} showSuccessResult + * @param {boolean} invalidateInput + */ async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) { + /** @type {Error[]} */ const allErrors = []; - const text = this._renderTextInput.value || ''; + const text = /** @type {HTMLInputElement} */ (this._renderTextInput).value; let result = `No definition found for ${text}`; try { const optionsContext = this._settingsController.getOptionsContext(); - const {dictionaryEntry, text: sentenceText} = await this._getDictionaryEntry(text, optionsContext); - if (dictionaryEntry !== null) { + const data = await this._getDictionaryEntry(text, optionsContext); + if (data !== null) { + const {dictionaryEntry, text: sentenceText} = data; const options = await this._settingsController.getOptions(); const context = { url: window.location.href, @@ -170,7 +245,7 @@ export class AnkiTemplatesController { let template = options.anki.fieldTemplates; if (typeof template !== 'string') { template = this._defaultFieldTemplates; } const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options; - const {note, errors} = await this._ankiNoteBuilder.createNote({ + const {note, errors} = await this._ankiNoteBuilder.createNote(/** @type {import('anki-note-builder').CreateNoteDetails} */ ({ dictionaryEntry, mode, context, @@ -183,28 +258,29 @@ export class AnkiTemplatesController { resultOutputMode, glossaryLayoutMode, compactTags - }); + })); result = note.fields.field; allErrors.push(...errors); } } catch (e) { - allErrors.push(e); + allErrors.push(e instanceof Error ? e : new Error(`${e}`)); } + /** + * @param {Error} e + * @returns {string} + */ const errorToMessageString = (e) => { - if (isObject(e)) { - let v = e.data; - if (isObject(v)) { - v = v.error; - if (isObject(v)) { - e = v; + if (e instanceof ExtensionError) { + const v = e.data; + if (typeof v === 'object' && v !== null) { + const v2 = /** @type {import('core').UnknownObject} */ (v).error; + if (v2 instanceof Error) { + return v2.message; } } - - v = e.message; - if (typeof v === 'string') { return v; } } - return `${e}`; + return e.message; }; const hasError = allErrors.length > 0; @@ -212,7 +288,7 @@ export class AnkiTemplatesController { infoNode.textContent = hasError ? allErrors.map(errorToMessageString).join('\n') : (showSuccessResult ? result : ''); infoNode.classList.toggle('text-danger', hasError); if (invalidateInput) { - this._fieldTemplatesTextarea.dataset.invalid = `${hasError}`; + /** @type {HTMLTextAreaElement} */ (this._fieldTemplatesTextarea).dataset.invalid = `${hasError}`; } } } diff --git a/ext/js/pages/settings/audio-controller.js b/ext/js/pages/settings/audio-controller.js index fb54ee6b..0a3f9454 100644 --- a/ext/js/pages/settings/audio-controller.js +++ b/ext/js/pages/settings/audio-controller.js @@ -19,48 +19,71 @@ import {EventDispatcher, EventListenerCollection} from '../../core.js'; import {AudioSystem} from '../../media/audio-system.js'; +/** + * @augments EventDispatcher<import('audio-controller').EventType> + */ export class AudioController extends EventDispatcher { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + */ constructor(settingsController, modalController) { super(); + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {AudioSystem} */ this._audioSystem = new AudioSystem(); + /** @type {?HTMLElement} */ this._audioSourceContainer = null; + /** @type {?HTMLButtonElement} */ this._audioSourceAddButton = null; + /** @type {AudioSourceEntry[]} */ this._audioSourceEntries = []; + /** @type {?HTMLInputElement} */ this._voiceTestTextInput = null; + /** @type {import('audio-controller').VoiceInfo[]} */ this._voices = []; } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._settingsController; } + /** @type {import('./modal-controller.js').ModalController} */ get modalController() { return this._modalController; } + /** */ async prepare() { this._audioSystem.prepare(); - this._voiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); - this._audioSourceContainer = document.querySelector('#audio-source-list'); - this._audioSourceAddButton = document.querySelector('#audio-source-add'); + this._voiceTestTextInput = /** @type {HTMLInputElement} */ (document.querySelector('#text-to-speech-voice-test-text')); + this._audioSourceContainer = /** @type {HTMLElement} */ (document.querySelector('#audio-source-list')); + this._audioSourceAddButton = /** @type {HTMLButtonElement} */ (document.querySelector('#audio-source-add')); this._audioSourceContainer.textContent = ''; + const testButton = /** @type {HTMLButtonElement} */ (document.querySelector('#text-to-speech-voice-test')); this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false); - this._audioSystem.on('voiceschanged', this._updateTextToSpeechVoices.bind(this), false); + this._audioSystem.on('voiceschanged', this._updateTextToSpeechVoices.bind(this)); this._updateTextToSpeechVoices(); - document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._onTestTextToSpeech.bind(this), false); + testButton.addEventListener('click', this._onTestTextToSpeech.bind(this), false); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** + * @param {AudioSourceEntry} entry + */ async removeSource(entry) { const {index} = entry; this._audioSourceEntries.splice(index, 1); @@ -78,16 +101,25 @@ export class AudioController extends EventDispatcher { }]); } + /** + * @returns {import('audio-controller').VoiceInfo[]} + */ getVoices() { return this._voices; } + /** + * @param {string} voice + */ setTestVoice(voice) { - this._voiceTestTextInput.dataset.voice = voice; + /** @type {HTMLInputElement} */ (this._voiceTestTextInput).dataset.voice = voice; } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { for (const entry of this._audioSourceEntries) { entry.cleanup(); @@ -100,15 +132,18 @@ export class AudioController extends EventDispatcher { } } + /** */ _onAddAudioSource() { this._addAudioSource(); } + /** */ _onTestTextToSpeech() { try { - const text = this._voiceTestTextInput.value || ''; - const voiceUri = this._voiceTestTextInput.dataset.voice; - const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); + const input = /** @type {HTMLInputElement} */ (this._voiceTestTextInput); + const text = input.value || ''; + const voiceUri = input.dataset.voice; + const audio = this._audioSystem.createTextToSpeechAudio(text, typeof voiceUri === 'string' ? voiceUri : ''); audio.volume = 1.0; audio.play(); } catch (e) { @@ -116,6 +151,7 @@ export class AudioController extends EventDispatcher { } } + /** */ _updateTextToSpeechVoices() { const voices = ( typeof speechSynthesis !== 'undefined' ? @@ -131,6 +167,11 @@ export class AudioController extends EventDispatcher { this.trigger('voicesUpdated'); } + /** + * @param {import('audio-controller').VoiceInfo} a + * @param {import('audio-controller').VoiceInfo} b + * @returns {number} + */ _textToSpeechVoiceCompare(a, b) { if (a.isJapanese) { if (!b.isJapanese) { return -1; } @@ -147,6 +188,10 @@ export class AudioController extends EventDispatcher { return a.index - b.index; } + /** + * @param {string} languageTag + * @returns {boolean} + */ _languageTagIsJapanese(languageTag) { return ( languageTag.startsWith('ja_') || @@ -155,15 +200,23 @@ export class AudioController extends EventDispatcher { ); } + /** + * @param {number} index + * @param {import('settings').AudioSourceOptions} source + */ _createAudioSourceEntry(index, source) { - const node = this._settingsController.instantiateTemplate('audio-source'); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('audio-source')); const entry = new AudioSourceEntry(this, index, source, node); this._audioSourceEntries.push(entry); - this._audioSourceContainer.appendChild(node); + /** @type {HTMLElement} */ (this._audioSourceContainer).appendChild(node); entry.prepare(); } + /** + * @returns {import('settings').AudioSourceType} + */ _getUnusedAudioSourceType() { + /** @type {import('settings').AudioSourceType[]} */ const typesAvailable = [ 'jpod101', 'jpod101-alternate', @@ -178,8 +231,10 @@ export class AudioController extends EventDispatcher { return typesAvailable[0]; } + /** */ async _addAudioSource() { const type = this._getUnusedAudioSourceType(); + /** @type {import('settings').AudioSourceOptions} */ const source = {type, url: '', voice: ''}; const index = this._audioSourceEntries.length; this._createAudioSourceEntry(index, source); @@ -194,19 +249,36 @@ export class AudioController extends EventDispatcher { } class AudioSourceEntry { + /** + * @param {AudioController} parent + * @param {number} index + * @param {import('settings').AudioSourceOptions} source + * @param {HTMLElement} node + */ constructor(parent, index, source, node) { + /** @type {AudioController} */ this._parent = parent; + /** @type {number} */ this._index = index; + /** @type {import('settings').AudioSourceType} */ this._type = source.type; + /** @type {string} */ this._url = source.url; + /** @type {string} */ this._voice = source.voice; + /** @type {HTMLElement} */ this._node = node; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLSelectElement} */ this._typeSelect = null; + /** @type {?HTMLInputElement} */ this._urlInput = null; + /** @type {?HTMLSelectElement} */ this._voiceSelect = null; } + /** @type {number} */ get index() { return this._index; } @@ -215,17 +287,19 @@ class AudioSourceEntry { this._index = value; } + /** @type {import('settings').AudioSourceType} */ get type() { return this._type; } + /** */ prepare() { this._updateTypeParameter(); - const menuButton = this._node.querySelector('.audio-source-menu-button'); - this._typeSelect = this._node.querySelector('.audio-source-type-select'); - this._urlInput = this._node.querySelector('.audio-source-parameter-container[data-field=url] .audio-source-parameter'); - this._voiceSelect = this._node.querySelector('.audio-source-parameter-container[data-field=voice] .audio-source-parameter'); + const menuButton = /** @type {HTMLButtonElement} */ (this._node.querySelector('.audio-source-menu-button')); + this._typeSelect = /** @type {HTMLSelectElement} */ (this._node.querySelector('.audio-source-type-select')); + this._urlInput = /** @type {HTMLInputElement} */ (this._node.querySelector('.audio-source-parameter-container[data-field=url] .audio-source-parameter')); + this._voiceSelect = /** @type {HTMLSelectElement} */ (this._node.querySelector('.audio-source-parameter-container[data-field=voice] .audio-source-parameter')); this._typeSelect.value = this._type; this._urlInput.value = this._url; @@ -239,6 +313,7 @@ class AudioSourceEntry { this._onVoicesUpdated(); } + /** */ cleanup() { if (this._node.parentNode !== null) { this._node.parentNode.removeChild(this._node); @@ -248,7 +323,9 @@ class AudioSourceEntry { // Private + /** */ _onVoicesUpdated() { + if (this._voiceSelect === null) { return; } const voices = this._parent.getVoices(); const fragment = document.createDocumentFragment(); @@ -270,18 +347,35 @@ class AudioSourceEntry { this._voiceSelect.value = this._voice; } + /** + * @param {Event} e + */ _onTypeSelectChange(e) { - this._setType(e.currentTarget.value); + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const value = this._normalizeAudioSourceType(element.value); + if (value === null) { return; } + this._setType(value); } + /** + * @param {Event} e + */ _onUrlInputChange(e) { - this._setUrl(e.currentTarget.value); + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + this._setUrl(element.value); } + /** + * @param {Event} e + */ _onVoiceSelectChange(e) { - this._setVoice(e.currentTarget.value); + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + this._setVoice(element.value); } + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const {menu} = e.detail; @@ -295,9 +389,15 @@ class AudioSourceEntry { break; } - menu.bodyNode.querySelector('.popup-menu-item[data-menu-action=help]').hidden = !hasHelp; + const helpNode = /** @type {?HTMLElement} */ (menu.bodyNode.querySelector('.popup-menu-item[data-menu-action=help]')); + if (helpNode !== null) { + helpNode.hidden = !hasHelp; + } } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'help': @@ -309,22 +409,32 @@ class AudioSourceEntry { } } + /** + * @param {import('settings').AudioSourceType} value + */ async _setType(value) { this._type = value; this._updateTypeParameter(); await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].type`, value); } + /** + * @param {string} value + */ async _setUrl(value) { this._url = value; await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].url`, value); } + /** + * @param {string} value + */ async _setVoice(value) { this._voice = value; await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].voice`, value); } + /** */ _updateTypeParameter() { let field = null; switch (this._type) { @@ -337,11 +447,14 @@ class AudioSourceEntry { field = 'voice'; break; } - for (const node of this._node.querySelectorAll('.audio-source-parameter-container')) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.audio-source-parameter-container'))) { node.hidden = (field === null || node.dataset.field !== field); } } + /** + * @param {import('settings').AudioSourceType} type + */ _showHelp(type) { switch (type) { case 'custom': @@ -358,7 +471,31 @@ class AudioSourceEntry { } } + /** + * @param {string} name + */ _showModal(name) { - this._parent.modalController.getModal(name).setVisible(true); + const modal = this._parent.modalController.getModal(name); + if (modal === null) { return; } + modal.setVisible(true); + } + + /** + * @param {string} value + * @returns {?import('settings').AudioSourceType} + */ + _normalizeAudioSourceType(value) { + switch (value) { + case 'jpod101': + case 'jpod101-alternate': + case 'jisho': + case 'text-to-speech': + case 'text-to-speech-reading': + case 'custom': + case 'custom-json': + return value; + default: + return null; + } } } diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js index 2863c505..bf44bb90 100644 --- a/ext/js/pages/settings/backup-controller.js +++ b/ext/js/pages/settings/backup-controller.js @@ -24,18 +24,37 @@ import {yomitan} from '../../yomitan.js'; import {DictionaryController} from './dictionary-controller.js'; export class BackupController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {?import('./modal-controller.js').ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {?import('core').TokenObject} */ this._settingsExportToken = null; + /** @type {?() => void} */ this._settingsExportRevoke = null; + /** @type {number} */ this._currentVersion = 0; + /** @type {?import('./modal.js').Modal} */ this._settingsResetModal = null; + /** @type {?import('./modal.js').Modal} */ this._settingsImportErrorModal = null; + /** @type {?import('./modal.js').Modal} */ this._settingsImportWarningModal = null; + /** @type {?OptionsUtil} */ this._optionsUtil = null; + /** + * + */ this._dictionariesDatabaseName = 'dict'; + /** + * + */ this._settingsExportDatabaseToken = null; try { @@ -45,6 +64,7 @@ export class BackupController { } } + /** */ async prepare() { if (this._optionsUtil !== null) { await this._optionsUtil.prepare(); @@ -69,13 +89,27 @@ export class BackupController { // Private - _addNodeEventListener(selector, ...args) { + /** + * @param {string} selector + * @param {string} eventName + * @param {(event: Event) => void} callback + * @param {boolean} capture + */ + _addNodeEventListener(selector, eventName, callback, capture) { const node = document.querySelector(selector); if (node === null) { return; } - node.addEventListener(...args); + node.addEventListener(eventName, callback, capture); } + /** + * @param {Date} date + * @param {string} dateSeparator + * @param {string} dateTimeSeparator + * @param {string} timeSeparator + * @param {number} resolution + * @returns {string} + */ _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { const values = [ date.getUTCFullYear().toString(), @@ -93,6 +127,10 @@ export class BackupController { return values.slice(0, resolution * 2 - 1).join(''); } + /** + * @param {Date} date + * @returns {Promise<import('backup-controller').BackupData>} + */ async _getSettingsExportData(date) { const optionsFull = await this._settingsController.getOptionsFull(); const environment = await yomitan.api.getEnvironmentInfo(); @@ -120,11 +158,19 @@ export class BackupController { return data; } + /** + * @param {Blob} blob + * @param {string} fileName + */ _saveBlob(blob, fileName) { - if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { - if (navigator.msSaveBlob(blob)) { - return; - } + if ( + typeof navigator === 'object' && navigator !== null && + // @ts-expect-error - call for legacy Edge + typeof navigator.msSaveBlob === 'function' && + // @ts-expect-error - call for legacy Edge + navigator.msSaveBlob(blob) + ) { + return; } const blobUrl = URL.createObjectURL(blob); @@ -146,6 +192,7 @@ export class BackupController { setTimeout(revoke, 60000); } + /** */ async _onSettingsExportClick() { if (this._settingsExportRevoke !== null) { this._settingsExportRevoke(); @@ -154,6 +201,7 @@ export class BackupController { const date = new Date(Date.now()); + /** @type {?import('core').TokenObject} */ const token = {}; this._settingsExportToken = token; const data = await this._getSettingsExportData(date); @@ -168,10 +216,14 @@ export class BackupController { this._saveBlob(blob, fileName); } + /** + * @param {File} file + * @returns {Promise<ArrayBuffer>} + */ _readFileArrayBuffer(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result); + reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result)); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(file); }); @@ -179,19 +231,33 @@ export class BackupController { // Importing + /** + * @param {import('settings').Options} optionsFull + */ async _settingsImportSetOptionsFull(optionsFull) { await this._settingsController.setAllSettings(optionsFull); } + /** + * @param {Error} error + */ _showSettingsImportError(error) { log.error(error); - document.querySelector('#settings-import-error-message').textContent = `${error}`; - this._settingsImportErrorModal.setVisible(true); + const element = /** @type {HTMLElement} */ (document.querySelector('#settings-import-error-message')); + element.textContent = `${error}`; + if (this._settingsImportErrorModal !== null) { + this._settingsImportErrorModal.setVisible(true); + } } + /** + * @param {Set<string>} warnings + * @returns {Promise<import('backup-controller').ShowSettingsImportWarningsResult>} + */ async _showSettingsImportWarnings(warnings) { const modal = this._settingsImportWarningModal; - const buttons = document.querySelectorAll('.settings-import-warning-import-button'); + if (modal === null) { return {result: false}; } + const buttons = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.settings-import-warning-import-button')); const messageContainer = document.querySelector('#settings-import-warning-message'); if (buttons.length === 0 || messageContainer === null) { return {result: false}; @@ -212,20 +278,30 @@ export class BackupController { // Wait for modal to close return new Promise((resolve) => { + /** + * @param {MouseEvent} e + */ const onButtonClick = (e) => { + const element = /** @type {HTMLElement} */ (e.currentTarget); e.preventDefault(); complete({ result: true, - sanitize: e.currentTarget.dataset.importSanitize === 'true' + sanitize: element.dataset.importSanitize === 'true' }); modal.setVisible(false); }; + /** + * @param {import('panel-element').VisibilityChangedEvent} details + */ const onModalVisibilityChanged = ({visible}) => { if (visible) { return; } complete({result: false}); }; let completed = false; + /** + * @param {import('backup-controller').ShowSettingsImportWarningsResult} result + */ const complete = (result) => { if (completed) { return; } completed = true; @@ -246,6 +322,10 @@ export class BackupController { }); } + /** + * @param {string} urlString + * @returns {boolean} + */ _isLocalhostUrl(urlString) { try { const url = new URL(urlString); @@ -266,6 +346,11 @@ export class BackupController { return false; } + /** + * @param {import('settings').ProfileOptions} options + * @param {boolean} dryRun + * @returns {string[]} + */ _settingsImportSanitizeProfileOptions(options, dryRun) { const warnings = []; @@ -308,6 +393,11 @@ export class BackupController { return warnings; } + /** + * @param {import('settings').Options} optionsFull + * @param {boolean} dryRun + * @returns {Set<string>} + */ _settingsImportSanitizeOptions(optionsFull, dryRun) { const warnings = new Set(); @@ -328,7 +418,12 @@ export class BackupController { return warnings; } + /** + * @param {File} file + */ async _importSettingsFile(file) { + if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); } + const dataString = ArrayBufferUtil.arrayBufferUtf8Decode(await this._readFileArrayBuffer(file)); const data = JSON.parse(dataString); @@ -383,31 +478,44 @@ export class BackupController { await this._settingsImportSetOptionsFull(optionsFull); } + /** */ _onSettingsImportClick() { - document.querySelector('#settings-import-file').click(); + const element = /** @type {HTMLElement} */ (document.querySelector('#settings-import-file')); + element.click(); } + /** + * @param {Event} e + */ async _onSettingsImportFileChange(e) { - const files = e.target.files; - if (files.length === 0) { return; } + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + const files = element.files; + if (files === null || files.length === 0) { return; } const file = files[0]; - e.target.value = null; + element.value = ''; try { await this._importSettingsFile(file); } catch (error) { - this._showSettingsImportError(error); + this._showSettingsImportError(error instanceof Error ? error : new Error(`${error}`)); } } // Resetting + /** */ _onSettingsResetClick() { + if (this._settingsResetModal === null) { return; } this._settingsResetModal.setVisible(true); } + /** */ async _onSettingsResetConfirmClick() { - this._settingsResetModal.setVisible(false); + if (this._optionsUtil === null) { throw new Error('OptionsUtil invalid'); } + + if (this._settingsResetModal !== null) { + this._settingsResetModal.setVisible(false); + } // Get default options const optionsFull = this._optionsUtil.getDefault(); @@ -425,8 +533,12 @@ export class BackupController { // Exporting Dictionaries Database + /** + * @param {string} message + * @param {boolean} [isWarning] + */ _databaseExportImportErrorMessage(message, isWarning=false) { - const errorMessageContainer = document.querySelector('#db-ops-error-report'); + const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report')); errorMessageContainer.style.display = 'block'; errorMessageContainer.textContent = message; @@ -439,9 +551,12 @@ export class BackupController { } } + /** + * @param {{totalRows: number, completedRows: number, done: boolean}} details + */ _databaseExportProgressCallback({totalRows, completedRows, done}) { console.log(`Progress: ${completedRows} of ${totalRows} rows completed`); - const messageContainer = document.querySelector('#db-ops-progress-report'); + const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report')); messageContainer.style.display = 'block'; messageContainer.textContent = `Export Progress: ${completedRows} of ${totalRows} rows completed`; @@ -451,6 +566,10 @@ export class BackupController { } } + /** + * @param {string} databaseName + * @returns {Promise<Blob>} + */ async _exportDatabase(databaseName) { const db = await new Dexie(databaseName).open(); const blob = await db.export({progressCallback: this._databaseExportProgressCallback}); @@ -458,6 +577,9 @@ export class BackupController { return blob; } + /** + * + */ async _onSettingsExportDatabaseClick() { if (this._settingsExportDatabaseToken !== null) { // An existing import or export is in progress. @@ -465,7 +587,7 @@ export class BackupController { return; } - const errorMessageContainer = document.querySelector('#db-ops-error-report'); + const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report')); errorMessageContainer.style.display = 'none'; const date = new Date(Date.now()); @@ -488,9 +610,12 @@ export class BackupController { // Importing Dictionaries Database + /** + * @param {{totalRows: number, completedRows: number, done: boolean}} details + */ _databaseImportProgressCallback({totalRows, completedRows, done}) { console.log(`Progress: ${completedRows} of ${totalRows} rows completed`); - const messageContainer = document.querySelector('#db-ops-progress-report'); + const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report')); messageContainer.style.display = 'block'; messageContainer.style.color = '#4169e1'; messageContainer.textContent = `Import Progress: ${completedRows} of ${totalRows} rows completed`; @@ -502,6 +627,10 @@ export class BackupController { } } + /** + * @param {string} databaseName + * @param {File} file + */ async _importDatabase(databaseName, file) { await yomitan.api.purgeDatabase(); await Dexie.import(file, {progressCallback: this._databaseImportProgressCallback}); @@ -509,10 +638,14 @@ export class BackupController { yomitan.trigger('storageChanged'); } + /** */ _onSettingsImportDatabaseClick() { - document.querySelector('#settings-import-db').click(); + /** @type {HTMLElement} */ (document.querySelector('#settings-import-db')).click(); } + /** + * @param {Event} e + */ async _onSettingsImportDatabaseChange(e) { if (this._settingsExportDatabaseToken !== null) { // An existing import or export is in progress. @@ -520,22 +653,23 @@ export class BackupController { return; } - const errorMessageContainer = document.querySelector('#db-ops-error-report'); + const errorMessageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-error-report')); errorMessageContainer.style.display = 'none'; - const files = e.target.files; - if (files.length === 0) { return; } + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + const files = element.files; + if (files === null || files.length === 0) { return; } const pageExitPrevention = this._settingsController.preventPageExit(); const file = files[0]; - e.target.value = null; + element.value = ''; try { const token = {}; this._settingsExportDatabaseToken = token; await this._importDatabase(this._dictionariesDatabaseName, file); } catch (error) { console.log(error); - const messageContainer = document.querySelector('#db-ops-progress-report'); + const messageContainer = /** @type {HTMLElement} */ (document.querySelector('#db-ops-progress-report')); messageContainer.style.color = 'red'; this._databaseExportImportErrorMessage('Encountered errors when importing. Please restart the browser and try again. If it continues to fail, reinstall Yomitan and import dictionaries one-by-one.'); } finally { diff --git a/ext/js/pages/settings/collapsible-dictionary-controller.js b/ext/js/pages/settings/collapsible-dictionary-controller.js index c8ce5e4f..355292ad 100644 --- a/ext/js/pages/settings/collapsible-dictionary-controller.js +++ b/ext/js/pages/settings/collapsible-dictionary-controller.js @@ -20,18 +20,29 @@ import {EventListenerCollection} from '../../core.js'; import {yomitan} from '../../yomitan.js'; export class CollapsibleDictionaryController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?import('core').TokenObject} */ this._getDictionaryInfoToken = null; + /** @type {Map<string, import('dictionary-importer').Summary>} */ this._dictionaryInfoMap = new Map(); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLElement} */ this._container = null; + /** @type {HTMLSelectElement[]} */ this._selects = []; + /** @type {?HTMLSelectElement} */ this._allSelect = null; } + /** */ async prepare() { - this._container = document.querySelector('#collapsible-dictionary-list'); + this._container = /** @type {HTMLElement} */ (document.querySelector('#collapsible-dictionary-list')); await this._onDatabaseUpdated(); @@ -42,7 +53,9 @@ export class CollapsibleDictionaryController { // Private + /** */ async _onDatabaseUpdated() { + /** @type {?import('core').TokenObject} */ const token = {}; this._getDictionaryInfoToken = token; const dictionaries = await this._settingsController.getDictionaryInfo(); @@ -54,10 +67,12 @@ export class CollapsibleDictionaryController { this._dictionaryInfoMap.set(entry.title, entry); } - const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + await this._onDictionarySettingsReordered(); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { this._eventListeners.removeAllEventListeners(); this._selects = []; @@ -79,25 +94,37 @@ export class CollapsibleDictionaryController { this._selects.push(select); } - this._container.textContent = ''; - this._container.appendChild(fragment); + const container = /** @type {HTMLElement} */ (this._container); + container.textContent = ''; + container.appendChild(fragment); } + /** */ _onDefinitionsCollapsibleChange() { this._updateAllSelectFresh(); } + /** + * @param {Event} e + */ _onAllSelectChange(e) { - const {value} = e.currentTarget; - if (value === 'varies') { return; } - this._setDefinitionsCollapsibleAll(value); + const {value} = /** @type {HTMLSelectElement} */ (e.currentTarget); + const value2 = this._normalizeDictionaryDefinitionsCollapsible(value); + if (value2 === null) { return; } + this._setDefinitionsCollapsibleAll(value2); } + /** */ async _onDictionarySettingsReordered() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** + * @param {DocumentFragment} fragment + * @param {import('settings').ProfileOptions} options + */ _setupAllSelect(fragment, options) { const select = this._addSelect(fragment, 'All', ''); @@ -113,23 +140,33 @@ export class CollapsibleDictionaryController { this._updateAllSelect(options); } + /** + * @param {DocumentFragment} fragment + * @param {string} dictionary + * @param {string} version + * @returns {HTMLSelectElement} + */ _addSelect(fragment, dictionary, version) { const node = this._settingsController.instantiateTemplate('collapsible-dictionary-item'); fragment.appendChild(node); - const nameNode = node.querySelector('.dictionary-title'); + const nameNode = /** @type {HTMLElement} */ (node.querySelector('.dictionary-title')); nameNode.textContent = dictionary; - const versionNode = node.querySelector('.dictionary-version'); + const versionNode = /** @type {HTMLElement} */ (node.querySelector('.dictionary-version')); versionNode.textContent = version; - return node.querySelector('.definitions-collapsible'); + return /** @type {HTMLSelectElement} */ (node.querySelector('.definitions-collapsible')); } + /** */ async _updateAllSelectFresh() { this._updateAllSelect(await this._settingsController.getOptions()); } + /** + * @param {import('settings').ProfileOptions} options + */ _updateAllSelect(options) { let value = null; let varies = false; @@ -142,11 +179,17 @@ export class CollapsibleDictionaryController { } } - this._allSelect.value = (varies || value === null ? 'varies' : value); + if (this._allSelect !== null) { + this._allSelect.value = (varies || value === null ? 'varies' : value); + } } + /** + * @param {import('settings').DictionaryDefinitionsCollapsible} value + */ async _setDefinitionsCollapsibleAll(value) { const options = await this._settingsController.getOptions(); + /** @type {import('settings-modifications').Modification[]} */ const targets = []; const {dictionaries} = options; for (let i = 0, ii = dictionaries.length; i < ii; ++i) { @@ -158,4 +201,21 @@ export class CollapsibleDictionaryController { select.value = value; } } + + /** + * @param {string} value + * @returns {?import('settings').DictionaryDefinitionsCollapsible} + */ + _normalizeDictionaryDefinitionsCollapsible(value) { + switch (value) { + case 'not-collapsible': + case 'expanded': + case 'collapsed': + case 'force-collapsed': + case 'force-expanded': + return value; + default: + return null; + } + } } diff --git a/ext/js/pages/settings/dictionary-controller.js b/ext/js/pages/settings/dictionary-controller.js index 155ce55e..de63b200 100644 --- a/ext/js/pages/settings/dictionary-controller.js +++ b/ext/js/pages/settings/dictionary-controller.js @@ -21,27 +21,49 @@ import {DictionaryWorker} from '../../language/dictionary-worker.js'; import {yomitan} from '../../yomitan.js'; class DictionaryEntry { + /** + * @param {DictionaryController} dictionaryController + * @param {DocumentFragment} fragment + * @param {number} index + * @param {import('dictionary-importer').Summary} dictionaryInfo + */ constructor(dictionaryController, fragment, index, dictionaryInfo) { + /** @type {DictionaryController} */ this._dictionaryController = dictionaryController; + /** @type {number} */ this._index = index; + /** @type {import('dictionary-importer').Summary} */ this._dictionaryInfo = dictionaryInfo; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?import('dictionary-database').DictionaryCountGroup} */ this._counts = null; + /** @type {ChildNode[]} */ this._nodes = [...fragment.childNodes]; - this._enabledCheckbox = fragment.querySelector('.dictionary-enabled'); - this._priorityInput = fragment.querySelector('.dictionary-priority'); - this._menuButton = fragment.querySelector('.dictionary-menu-button'); - this._outdatedButton = fragment.querySelector('.dictionary-outdated-button'); - this._integrityButton = fragment.querySelector('.dictionary-integrity-button'); - this._titleNode = fragment.querySelector('.dictionary-title'); - this._versionNode = fragment.querySelector('.dictionary-version'); - this._titleContainer = fragment.querySelector('.dictionary-item-title-container'); - } - + /** @type {HTMLInputElement} */ + this._enabledCheckbox = /** @type {HTMLInputElement} */ (fragment.querySelector('.dictionary-enabled')); + /** @type {HTMLInputElement} */ + this._priorityInput = /** @type {HTMLInputElement} */ (fragment.querySelector('.dictionary-priority')); + /** @type {HTMLButtonElement} */ + this._menuButton = /** @type {HTMLButtonElement} */ (fragment.querySelector('.dictionary-menu-button')); + /** @type {HTMLButtonElement} */ + this._outdatedButton = /** @type {HTMLButtonElement} */ (fragment.querySelector('.dictionary-outdated-button')); + /** @type {HTMLButtonElement} */ + this._integrityButton = /** @type {HTMLButtonElement} */ (fragment.querySelector('.dictionary-integrity-button')); + /** @type {HTMLElement} */ + this._titleNode = /** @type {HTMLElement} */ (fragment.querySelector('.dictionary-title')); + /** @type {HTMLElement} */ + this._versionNode = /** @type {HTMLElement} */ (fragment.querySelector('.dictionary-version')); + /** @type {HTMLElement} */ + this._titleContainer = /** @type {HTMLElement} */ (fragment.querySelector('.dictionary-item-title-container')); + } + + /** @type {string} */ get dictionaryTitle() { return this._dictionaryInfo.title; } + /** */ prepare() { const index = this._index; const {title, revision, version} = this._dictionaryInfo; @@ -58,6 +80,7 @@ class DictionaryEntry { this._eventListeners.addEventListener(this._integrityButton, 'click', this._onIntegrityButtonClick.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); for (const node of this._nodes) { @@ -68,17 +91,26 @@ class DictionaryEntry { this._nodes = []; } + /** + * @param {import('dictionary-database').DictionaryCountGroup} counts + */ setCounts(counts) { this._counts = counts; this._integrityButton.hidden = false; } + /** + * @param {boolean} value + */ setEnabled(value) { this._enabledCheckbox.checked = value; } // Private + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const bodyNode = e.detail.menu.bodyNode; const count = this._dictionaryController.dictionaryOptionCount; @@ -87,6 +119,9 @@ class DictionaryEntry { this._setMenuActionEnabled(bodyNode, 'moveTo', count > 1); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'delete': @@ -107,36 +142,48 @@ class DictionaryEntry { } } + /** + * @param {import('dom-data-binder').SettingChangedEvent} e + */ _onEnabledChanged(e) { const {detail: {value}} = e; this._titleContainer.dataset.enabled = `${value}`; this._dictionaryController.updateDictionariesEnabled(); } + /** */ _onOutdatedButtonClick() { this._showDetails(); } + /** */ _onIntegrityButtonClick() { this._showDetails(); } + /** */ _showDetails() { const {title, revision, version, prefixWildcardsSupported} = this._dictionaryInfo; const modal = this._dictionaryController.modalController.getModal('dictionary-details'); + if (modal === null) { return; } - modal.node.querySelector('.dictionary-title').textContent = title; - modal.node.querySelector('.dictionary-version').textContent = `rev.${revision}`; - modal.node.querySelector('.dictionary-outdated-notification').hidden = (version >= 3); - modal.node.querySelector('.dictionary-counts').textContent = this._counts !== null ? JSON.stringify(this._counts, null, 4) : ''; - modal.node.querySelector('.dictionary-prefix-wildcard-searches-supported').checked = prefixWildcardsSupported; - this._setupDetails(modal.node.querySelector('.dictionary-details-table')); + /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-title')).textContent = title; + /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-version')).textContent = `rev.${revision}`; + /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-outdated-notification')).hidden = (version >= 3); + /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-counts')).textContent = this._counts !== null ? JSON.stringify(this._counts, null, 4) : ''; + /** @type {HTMLInputElement} */ (modal.node.querySelector('.dictionary-prefix-wildcard-searches-supported')).checked = prefixWildcardsSupported; + this._setupDetails(/** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-details-table'))); modal.setVisible(true); } + /** + * @param {Element} detailsTable + * @returns {boolean} + */ _setupDetails(detailsTable) { + /** @type {[label: string, key: 'author'|'url'|'description'|'attribution'][]} */ const targets = [ ['Author', 'author'], ['URL', 'url'], @@ -151,10 +198,10 @@ class DictionaryEntry { const info = dictionaryInfo[key]; if (typeof info !== 'string') { continue; } - const details = this._dictionaryController.instantiateTemplate('dictionary-details-entry'); + const details = /** @type {HTMLElement} */ (this._dictionaryController.instantiateTemplate('dictionary-details-entry')); details.dataset.type = key; - details.querySelector('.dictionary-details-entry-label').textContent = `${label}:`; - details.querySelector('.dictionary-details-entry-info').textContent = info; + /** @type {HTMLElement} */ (details.querySelector('.dictionary-details-entry-label')).textContent = `${label}:`; + /** @type {HTMLElement} */ (details.querySelector('.dictionary-details-entry-info')).textContent = info; fragment.appendChild(details); any = true; @@ -165,28 +212,40 @@ class DictionaryEntry { return any; } + /** */ _delete() { this._dictionaryController.deleteDictionary(this.dictionaryTitle); } + /** + * @param {number} offset + */ _move(offset) { this._dictionaryController.moveDictionaryOptions(this._index, this._index + offset); } + /** + * @param {Element} menu + * @param {string} action + * @param {boolean} enabled + */ _setMenuActionEnabled(menu, action, enabled) { - const element = menu.querySelector(`[data-menu-action="${action}"]`); + const element = /** @type {?HTMLButtonElement} */ (menu.querySelector(`[data-menu-action="${action}"]`)); if (element === null) { return; } element.disabled = !enabled; } + /** */ _showMoveToModal() { const {title} = this._dictionaryInfo; const count = this._dictionaryController.dictionaryOptionCount; const modal = this._dictionaryController.modalController.getModal('dictionary-move-location'); - const input = modal.node.querySelector('#dictionary-move-location'); + if (modal === null) { return; } + const input = /** @type {HTMLInputElement} */ (modal.node.querySelector('#dictionary-move-location')); + const titleNode = /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-title')); modal.node.dataset.index = `${this._index}`; - modal.node.querySelector('.dictionary-title').textContent = title; + titleNode.textContent = title; input.value = `${this._index + 1}`; input.max = `${count}`; @@ -195,25 +254,45 @@ class DictionaryEntry { } class DictionaryExtraInfo { + /** + * @param {DictionaryController} parent + * @param {import('dictionary-database').DictionaryCountGroup} totalCounts + * @param {import('dictionary-database').DictionaryCountGroup} remainders + * @param {number} totalRemainder + */ constructor(parent, totalCounts, remainders, totalRemainder) { + /** @type {DictionaryController} */ this._parent = parent; + /** @type {import('dictionary-database').DictionaryCountGroup} */ this._totalCounts = totalCounts; + /** @type {import('dictionary-database').DictionaryCountGroup} */ this._remainders = remainders; + /** @type {number} */ this._totalRemainder = totalRemainder; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); - this._nodes = null; + /** @type {ChildNode[]} */ + this._nodes = []; } + /** + * @param {HTMLElement} container + */ prepare(container) { const fragment = this._parent.instantiateTemplateFragment('dictionary-extra'); - this._nodes = [...fragment.childNodes]; + for (const node of fragment.childNodes) { + this._nodes.push(node); + } + + const dictionaryIntegrityButton = /** @type {HTMLButtonElement} */ (fragment.querySelector('.dictionary-integrity-button')); this._setTitle(fragment.querySelector('.dictionary-total-count')); - this._eventListeners.addEventListener(fragment.querySelector('.dictionary-integrity-button'), 'click', this._onIntegrityButtonClick.bind(this), false); + this._eventListeners.addEventListener(dictionaryIntegrityButton, 'click', this._onIntegrityButtonClick.bind(this), false); container.appendChild(fragment); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); for (const node of this._nodes) { @@ -221,74 +300,110 @@ class DictionaryExtraInfo { node.parentNode.removeChild(node); } } - this._nodes = []; + this._nodes.length =0; } // Private + /** */ _onIntegrityButtonClick() { this._showDetails(); } + /** */ _showDetails() { const modal = this._parent.modalController.getModal('dictionary-extra-data'); + if (modal === null) { return; } + + const dictionaryCounts = /** @type {HTMLElement} */ (modal.node.querySelector('.dictionary-counts')); const info = {counts: this._totalCounts, remainders: this._remainders}; - modal.node.querySelector('.dictionary-counts').textContent = JSON.stringify(info, null, 4); + dictionaryCounts.textContent = JSON.stringify(info, null, 4); this._setTitle(modal.node.querySelector('.dictionary-total-count')); modal.setVisible(true); } + /** + * @param {?Element} node + */ _setTitle(node) { + if (node === null) { return; } node.textContent = `${this._totalRemainder} item${this._totalRemainder !== 1 ? 's' : ''}`; } } export class DictionaryController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + * @param {import('./status-footer.js').StatusFooter} statusFooter + */ constructor(settingsController, modalController, statusFooter) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {import('./status-footer.js').StatusFooter} */ this._statusFooter = statusFooter; + /** @type {?import('dictionary-importer').Summary[]} */ this._dictionaries = null; + /** @type {DictionaryEntry[]} */ this._dictionaryEntries = []; + /** @type {?import('core').TokenObject} */ this._databaseStateToken = null; + /** @type {boolean} */ this._checkingIntegrity = false; + /** @type {?HTMLButtonElement} */ this._checkIntegrityButton = null; + /** @type {?HTMLElement} */ this._dictionaryEntryContainer = null; + /** @type {?HTMLElement} */ this._dictionaryInstallCountNode = null; + /** @type {?HTMLElement} */ this._dictionaryEnabledCountNode = null; + /** @type {?NodeListOf<HTMLElement>} */ this._noDictionariesInstalledWarnings = null; + /** @type {?NodeListOf<HTMLElement>} */ this._noDictionariesEnabledWarnings = null; + /** @type {?import('./modal.js').Modal} */ this._deleteDictionaryModal = null; + /** @type {?HTMLInputElement} */ this._allCheckbox = null; + /** @type {?DictionaryExtraInfo} */ this._extraInfo = null; + /** @type {boolean} */ this._isDeleting = false; } + /** @type {import('./modal-controller.js').ModalController} */ get modalController() { return this._modalController; } + /** @type {number} */ get dictionaryOptionCount() { return this._dictionaryEntries.length; } + /** */ async prepare() { - this._checkIntegrityButton = document.querySelector('#dictionary-check-integrity'); - this._dictionaryEntryContainer = document.querySelector('#dictionary-list'); - this._dictionaryInstallCountNode = document.querySelector('#dictionary-install-count'); - this._dictionaryEnabledCountNode = document.querySelector('#dictionary-enabled-count'); - this._noDictionariesInstalledWarnings = document.querySelectorAll('.no-dictionaries-installed-warning'); - this._noDictionariesEnabledWarnings = document.querySelectorAll('.no-dictionaries-enabled-warning'); + this._checkIntegrityButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-check-integrity')); + this._dictionaryEntryContainer = /** @type {HTMLElement} */ (document.querySelector('#dictionary-list')); + this._dictionaryInstallCountNode = /** @type {HTMLElement} */ (document.querySelector('#dictionary-install-count')); + this._dictionaryEnabledCountNode = /** @type {HTMLElement} */ (document.querySelector('#dictionary-enabled-count')); + this._noDictionariesInstalledWarnings = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.no-dictionaries-installed-warning')); + this._noDictionariesEnabledWarnings = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.no-dictionaries-enabled-warning')); this._deleteDictionaryModal = this._modalController.getModal('dictionary-confirm-delete'); - this._allCheckbox = document.querySelector('#all-dictionaries-enabled'); + this._allCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#all-dictionaries-enabled')); + const dictionaryDeleteButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-confirm-delete-button')); + const dictionaryMoveButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-move-button')); yomitan.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); this._allCheckbox.addEventListener('change', this._onAllCheckboxChange.bind(this), false); - document.querySelector('#dictionary-confirm-delete-button').addEventListener('click', this._onDictionaryConfirmDelete.bind(this), false); - document.querySelector('#dictionary-move-button').addEventListener('click', this._onDictionaryMoveButtonClick.bind(this), false); + dictionaryDeleteButton.addEventListener('click', this._onDictionaryConfirmDelete.bind(this), false); + dictionaryMoveButton.addEventListener('click', this._onDictionaryMoveButtonClick.bind(this), false); if (this._checkIntegrityButton !== null) { this._checkIntegrityButton.addEventListener('click', this._onCheckIntegrityButtonClick.bind(this), false); } @@ -298,14 +413,22 @@ export class DictionaryController { await this._onDatabaseUpdated(); } + /** + * @param {string} dictionaryTitle + */ deleteDictionary(dictionaryTitle) { if (this._isDeleting) { return; } - const modal = this._deleteDictionaryModal; + const modal = /** @type {import('./modal.js').Modal} */ (this._deleteDictionaryModal); modal.node.dataset.dictionaryTitle = dictionaryTitle; - modal.node.querySelector('#dictionary-confirm-delete-name').textContent = dictionaryTitle; + const nameElement = /** @type {Element} */ (modal.node.querySelector('#dictionary-confirm-delete-name')); + nameElement.textContent = dictionaryTitle; modal.setVisible(true); } + /** + * @param {number} currentIndex + * @param {number} targetIndex + */ async moveDictionaryOptions(currentIndex, targetIndex) { const options = await this._settingsController.getOptions(); const {dictionaries} = options; @@ -326,24 +449,40 @@ export class DictionaryController { value: dictionaries }]); - this._settingsController.trigger('dictionarySettingsReordered', {source: this}); + /** @type {import('settings-controller').DictionarySettingsReorderedEvent} */ + const event = {source: this}; + this._settingsController.trigger('dictionarySettingsReordered', event); await this._updateEntries(); } + /** + * @param {string} name + * @returns {Element} + */ instantiateTemplate(name) { return this._settingsController.instantiateTemplate(name); } + /** + * @param {string} name + * @returns {DocumentFragment} + */ instantiateTemplateFragment(name) { return this._settingsController.instantiateTemplateFragment(name); } + /** */ async updateDictionariesEnabled() { const options = await this._settingsController.getOptions(); this._updateDictionariesEnabledWarnings(options); } + /** + * @param {string} name + * @param {boolean} enabled + * @returns {import('settings').DictionaryOptions} + */ static createDefaultDictionarySettings(name, enabled) { return { name, @@ -354,6 +493,13 @@ export class DictionaryController { }; } + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('dictionary-importer').Summary[]|undefined} dictionaries + * @param {import('settings').Options|undefined} optionsFull + * @param {boolean} modifyGlobalSettings + * @param {boolean} newDictionariesEnabled + */ static async ensureDictionarySettings(settingsController, dictionaries, optionsFull, modifyGlobalSettings, newDictionariesEnabled) { if (typeof dictionaries === 'undefined') { dictionaries = await settingsController.getDictionaryInfo(); @@ -367,6 +513,7 @@ export class DictionaryController { installedDictionaries.add(title); } + /** @type {import('settings-modifications').Modification[]} */ const targets = []; const {profiles} = optionsFull; for (let i = 0, ii = profiles.length; i < ii; ++i) { @@ -405,6 +552,9 @@ export class DictionaryController { // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { this._updateDictionariesEnabledWarnings(options); if (this._dictionaries !== null) { @@ -412,7 +562,9 @@ export class DictionaryController { } } + /** */ async _onDatabaseUpdated() { + /** @type {?import('core').TokenObject} */ const token = {}; this._databaseStateToken = token; this._dictionaries = null; @@ -423,14 +575,18 @@ export class DictionaryController { await this._updateEntries(); } + /** */ _onAllCheckboxChange() { - const value = this._allCheckbox.checked; - this._allCheckbox.checked = !value; + const allCheckbox = /** @type {HTMLInputElement} */ (this._allCheckbox); + const value = allCheckbox.checked; + allCheckbox.checked = !value; this._setAllDictionariesEnabled(value); } + /** */ async _updateEntries() { const dictionaries = this._dictionaries; + if (dictionaries === null) { return; } this._updateMainDictionarySelectOptions(dictionaries); for (const entry of this._dictionaryEntries) { @@ -444,7 +600,7 @@ export class DictionaryController { } const hasDictionary = (dictionaries.length > 0); - for (const node of this._noDictionariesInstalledWarnings) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._noDictionariesInstalledWarnings)) { node.hidden = hasDictionary; } @@ -453,8 +609,9 @@ export class DictionaryController { const options = await this._settingsController.getOptions(); this._updateDictionariesEnabledWarnings(options); + /** @type {Map<string, import('dictionary-importer').Summary>} */ const dictionaryInfoMap = new Map(); - for (const dictionary of this._dictionaries) { + for (const dictionary of dictionaries) { dictionaryInfoMap.set(dictionary.title, dictionary); } @@ -467,6 +624,9 @@ export class DictionaryController { } } + /** + * @param {import('settings').ProfileOptions} options + */ _updateDictionariesEnabledWarnings(options) { const {dictionaries} = options; let enabledDictionaryCountValid = 0; @@ -489,7 +649,7 @@ export class DictionaryController { } const hasEnabledDictionary = (enabledDictionaryCountValid > 0); - for (const node of this._noDictionariesEnabledWarnings) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._noDictionariesEnabledWarnings)) { node.hidden = hasEnabledDictionary; } @@ -497,7 +657,7 @@ export class DictionaryController { this._dictionaryEnabledCountNode.textContent = `${enabledDictionaryCountValid}`; } - this._allCheckbox.checked = (enabledDictionaryCount >= dictionaryCount); + /** @type {HTMLInputElement} */ (this._allCheckbox).checked = (enabledDictionaryCount >= dictionaryCount); const entries = this._dictionaryEntries; for (let i = 0, ii = Math.min(entries.length, dictionaryCount); i < ii; ++i) { @@ -505,10 +665,13 @@ export class DictionaryController { } } + /** + * @param {MouseEvent} e + */ _onDictionaryConfirmDelete(e) { e.preventDefault(); - const modal = this._deleteDictionaryModal; + const modal = /** @type {import('./modal.js').Modal} */ (this._deleteDictionaryModal); modal.setVisible(false); const title = modal.node.dataset.dictionaryTitle; @@ -518,24 +681,32 @@ export class DictionaryController { this._deleteDictionary(title); } + /** + * @param {MouseEvent} e + */ _onCheckIntegrityButtonClick(e) { e.preventDefault(); this._checkIntegrity(); } + /** */ _onDictionaryMoveButtonClick() { - const modal = this._modalController.getModal('dictionary-move-location'); - let {index} = modal.node.dataset; - index = Number.parseInt(index, 10); + const modal = /** @type {import('./modal.js').Modal} */ (this._modalController.getModal('dictionary-move-location')); + const {index} = modal.node.dataset; + if (typeof index !== 'number') { return; } + const indexNumber = Number.parseInt(index, 10); - let target = document.querySelector('#dictionary-move-location').value; - target = Number.parseInt(target, 10) - 1; + const targetString = /** @type {HTMLInputElement} */ (document.querySelector('#dictionary-move-location')).value; + const target = Number.parseInt(targetString, 10) - 1; - if (!Number.isFinite(target) || !Number.isFinite(index) || index === target) { return; } + if (!Number.isFinite(target) || !Number.isFinite(indexNumber) || indexNumber === target) { return; } - this.moveDictionaryOptions(index, target); + this.moveDictionaryOptions(indexNumber, target); } + /** + * @param {import('dictionary-importer').Summary[]} dictionaries + */ _updateMainDictionarySelectOptions(dictionaries) { for (const select of document.querySelectorAll('[data-setting="general.mainDictionary"]')) { const fragment = document.createDocumentFragment(); @@ -559,6 +730,7 @@ export class DictionaryController { } } + /** */ async _checkIntegrity() { if (this._dictionaries === null || this._checkingIntegrity || this._isDeleting) { return; } @@ -576,13 +748,17 @@ export class DictionaryController { entry.setCounts(counts[i]); } - this._setCounts(counts, total); + this._setCounts(counts, /** @type {import('dictionary-database').DictionaryCountGroup} */ (total)); } finally { this._setButtonsEnabled(true); this._checkingIntegrity = false; } } + /** + * @param {import('dictionary-database').DictionaryCountGroup[]} dictionaryCounts + * @param {import('dictionary-database').DictionaryCountGroup} totalCounts + */ _setCounts(dictionaryCounts, totalCounts) { const remainders = Object.assign({}, totalCounts); const keys = Object.keys(remainders); @@ -603,12 +779,16 @@ export class DictionaryController { this._extraInfo = null; } - if (totalRemainder > 0) { + if (totalRemainder > 0 && this._dictionaryEntryContainer !== null) { this._extraInfo = new DictionaryExtraInfo(this, totalCounts, remainders, totalRemainder); this._extraInfo.prepare(this._dictionaryEntryContainer); } } + /** + * @param {number} index + * @param {import('dictionary-importer').Summary} dictionaryInfo + */ _createDictionaryEntry(index, dictionaryInfo) { const fragment = this.instantiateTemplateFragment('dictionary'); @@ -616,13 +796,16 @@ export class DictionaryController { this._dictionaryEntries.push(entry); entry.prepare(); - const container = this._dictionaryEntryContainer; + const container = /** @type {HTMLElement} */ (this._dictionaryEntryContainer); const relative = container.querySelector('.dictionary-item-bottom'); container.insertBefore(fragment, relative); this._updateDictionaryEntryCount(); } + /** + * @param {string} dictionaryTitle + */ async _deleteDictionary(dictionaryTitle) { if (this._isDeleting || this._checkingIntegrity) { return; } @@ -631,15 +814,18 @@ export class DictionaryController { const statusFooter = this._statusFooter; const progressSelector = '.dictionary-delete-progress'; - const progressContainers = document.querySelectorAll(`#dictionaries-modal ${progressSelector}`); - const progressBars = document.querySelectorAll(`${progressSelector} .progress-bar`); - const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`); - const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`); + const progressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#dictionaries-modal ${progressSelector}`)); + const progressBars = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-bar`)); + const infoLabels = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-info`)); + const statusLabels = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-status`)); const prevention = this._settingsController.preventPageExit(); try { this._isDeleting = true; this._setButtonsEnabled(false); + /** + * @param {import('dictionary-database').DeleteDictionaryProgressData} details + */ const onProgress = ({processed, count, storeCount, storesProcesed}) => { const percent = ( (count > 0 && storesProcesed > 0) ? @@ -672,21 +858,32 @@ export class DictionaryController { } } + /** + * @param {boolean} value + */ _setButtonsEnabled(value) { value = !value; - for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) { + for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.dictionary-database-mutating-input'))) { node.disabled = value; } } + /** + * @param {string} dictionaryTitle + * @param {import('dictionary-worker').DeleteProgressCallback} onProgress + */ async _deleteDictionaryInternal(dictionaryTitle, onProgress) { await new DictionaryWorker().deleteDictionary(dictionaryTitle, onProgress); yomitan.api.triggerDatabaseUpdated('dictionary', 'delete'); } + /** + * @param {string} dictionaryTitle + */ async _deleteDictionarySettings(dictionaryTitle) { const optionsFull = await this._settingsController.getOptionsFull(); const {profiles} = optionsFull; + /** @type {import('settings-modifications').Modification[]} */ const targets = []; for (let i = 0, ii = profiles.length; i < ii; ++i) { const {options: {dictionaries}} = profiles[i]; @@ -705,18 +902,24 @@ export class DictionaryController { await this._settingsController.modifyGlobalSettings(targets); } + /** */ _triggerStorageChanged() { yomitan.trigger('storageChanged'); } + /** */ _updateDictionaryEntryCount() { - this._dictionaryEntryContainer.dataset.count = `${this._dictionaryEntries.length}`; + /** @type {HTMLElement} */ (this._dictionaryEntryContainer).dataset.count = `${this._dictionaryEntries.length}`; } + /** + * @param {boolean} value + */ async _setAllDictionariesEnabled(value) { const options = await this._settingsController.getOptions(); const {dictionaries} = options; + /** @type {import('settings-modifications').Modification[]} */ const targets = []; for (let i = 0, ii = dictionaries.length; i < ii; ++i) { targets.push({ diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js index 12d29a6f..d1255e11 100644 --- a/ext/js/pages/settings/dictionary-import-controller.js +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -16,25 +16,44 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, log} from '../../core.js'; +import {log} from '../../core.js'; +import {ExtensionError} from '../../core/extension-error.js'; import {DictionaryWorker} from '../../language/dictionary-worker.js'; import {yomitan} from '../../yomitan.js'; import {DictionaryController} from './dictionary-controller.js'; export class DictionaryImportController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + * @param {import('./status-footer.js').StatusFooter} statusFooter + */ constructor(settingsController, modalController, statusFooter) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {import('./status-footer.js').StatusFooter} */ this._statusFooter = statusFooter; + /** @type {boolean} */ this._modifying = false; + /** @type {?HTMLButtonElement} */ this._purgeButton = null; + /** @type {?HTMLButtonElement} */ this._purgeConfirmButton = null; + /** @type {?HTMLButtonElement} */ this._importFileButton = null; + /** @type {?HTMLInputElement} */ this._importFileInput = null; + /** @type {?import('./modal.js').Modal} */ this._purgeConfirmModal = null; + /** @type {?HTMLElement} */ this._errorContainer = null; + /** @type {?HTMLElement} */ this._spinner = null; + /** @type {?HTMLElement} */ this._purgeNotification = null; + /** @type {[originalMessage: string, newMessage: string][]} */ this._errorToStringOverrides = [ [ 'A mutation operation was attempted on a database that did not allow mutations.', @@ -47,15 +66,16 @@ export class DictionaryImportController { ]; } + /** */ async prepare() { - this._purgeButton = document.querySelector('#dictionary-delete-all-button'); - this._purgeConfirmButton = document.querySelector('#dictionary-confirm-delete-all-button'); - this._importFileButton = document.querySelector('#dictionary-import-file-button'); - this._importFileInput = document.querySelector('#dictionary-import-file-input'); + this._purgeButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-delete-all-button')); + this._purgeConfirmButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-confirm-delete-all-button')); + this._importFileButton = /** @type {HTMLButtonElement} */ (document.querySelector('#dictionary-import-file-button')); + this._importFileInput = /** @type {HTMLInputElement} */ (document.querySelector('#dictionary-import-file-input')); this._purgeConfirmModal = this._modalController.getModal('dictionary-confirm-delete-all'); - this._errorContainer = document.querySelector('#dictionary-error'); - this._spinner = document.querySelector('#dictionary-spinner'); - this._purgeNotification = document.querySelector('#dictionary-delete-all-status'); + this._errorContainer = /** @type {HTMLElement} */ (document.querySelector('#dictionary-error')); + this._spinner = /** @type {HTMLElement} */ (document.querySelector('#dictionary-spinner')); + this._purgeNotification = /** @type {HTMLElement} */ (document.querySelector('#dictionary-delete-all-status')); this._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false); this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false); @@ -65,28 +85,41 @@ export class DictionaryImportController { // Private + /** */ _onImportButtonClick() { - this._importFileInput.click(); + /** @type {HTMLInputElement} */ (this._importFileInput).click(); } + /** + * @param {MouseEvent} e + */ _onPurgeButtonClick(e) { e.preventDefault(); - this._purgeConfirmModal.setVisible(true); + /** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(true); } + /** + * @param {MouseEvent} e + */ _onPurgeConfirmButtonClick(e) { e.preventDefault(); - this._purgeConfirmModal.setVisible(false); + /** @type {import('./modal.js').Modal} */ (this._purgeConfirmModal).setVisible(false); this._purgeDatabase(); } + /** + * @param {Event} e + */ _onImportFileChange(e) { - const node = e.currentTarget; - const files = [...node.files]; - node.value = null; - this._importDictionaries(files); + const node = /** @type {HTMLInputElement} */ (e.currentTarget); + const {files} = node; + if (files === null) { return; } + const files2 = [...files]; + node.value = ''; + this._importDictionaries(files2); } + /** */ async _purgeDatabase() { if (this._modifying) { return; } @@ -106,7 +139,7 @@ export class DictionaryImportController { this._showErrors(errors); } } catch (error) { - this._showErrors([error]); + this._showErrors([error instanceof Error ? error : new Error(`${error}`)]); } finally { prevention.end(); if (purgeNotification !== null) { purgeNotification.hidden = true; } @@ -116,16 +149,19 @@ export class DictionaryImportController { } } + /** + * @param {File[]} files + */ async _importDictionaries(files) { if (this._modifying) { return; } const statusFooter = this._statusFooter; - const importInfo = document.querySelector('#dictionary-import-info'); + const importInfo = /** @type {HTMLElement} */ (document.querySelector('#dictionary-import-info')); const progressSelector = '.dictionary-import-progress'; - const progressContainers = document.querySelectorAll(`#dictionaries-modal ${progressSelector}`); - const progressBars = document.querySelectorAll(`${progressSelector} .progress-bar`); - const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`); - const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`); + const progressContainers = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`#dictionaries-modal ${progressSelector}`)); + const progressBars = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-bar`)); + const infoLabels = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-info`)); + const statusLabels = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll(`${progressSelector} .progress-status`)); const prevention = this._preventPageExit(); @@ -143,6 +179,7 @@ export class DictionaryImportController { let statusPrefix = ''; let stepIndex = -2; + /** @type {import('dictionary-worker').ImportProgressCallback} */ const onProgress = (data) => { const {stepIndex: stepIndex2, index, count} = data; if (stepIndex !== stepIndex2) { @@ -184,7 +221,7 @@ export class DictionaryImportController { await this._importDictionary(files[i], importDetails, onProgress); } } catch (err) { - this._showErrors([err]); + this._showErrors([err instanceof Error ? err : new Error(`${err}`)]); } finally { prevention.end(); for (const progress of progressContainers) { progress.hidden = true; } @@ -199,6 +236,10 @@ export class DictionaryImportController { } } + /** + * @param {number} stepIndex + * @returns {string} + */ _getImportLabel(stepIndex) { switch (stepIndex) { case -1: @@ -212,6 +253,11 @@ export class DictionaryImportController { } } + /** + * @param {File} file + * @param {import('dictionary-importer').ImportDetails} importDetails + * @param {import('dictionary-worker').ImportProgressCallback} onProgress + */ async _importDictionary(file, importDetails, onProgress) { const archiveContent = await this._readFile(file); const {result, errors} = await new DictionaryWorker().importDictionary(archiveContent, importDetails, onProgress); @@ -225,8 +271,14 @@ export class DictionaryImportController { } } + /** + * @param {boolean} sequenced + * @param {string} title + * @returns {Promise<Error[]>} + */ async _addDictionarySettings(sequenced, title) { const optionsFull = await this._settingsController.getOptionsFull(); + /** @type {import('settings-modifications').Modification[]} */ const targets = []; const profileCount = optionsFull.profiles.length; for (let i = 0; i < profileCount; ++i) { @@ -243,8 +295,12 @@ export class DictionaryImportController { return await this._modifyGlobalSettings(targets); } + /** + * @returns {Promise<Error[]>} + */ async _clearDictionarySettings() { const optionsFull = await this._settingsController.getOptionsFull(); + /** @type {import('settings-modifications').Modification[]} */ const targets = []; const profileCount = optionsFull.profiles.length; for (let i = 0; i < profileCount; ++i) { @@ -256,16 +312,25 @@ export class DictionaryImportController { return await this._modifyGlobalSettings(targets); } + /** + * @param {boolean} visible + */ _setSpinnerVisible(visible) { if (this._spinner !== null) { this._spinner.hidden = !visible; } } + /** + * @returns {import('settings-controller').PageExitPrevention} + */ _preventPageExit() { return this._settingsController.preventPageExit(); } + /** + * @param {Error[]} errors + */ _showErrors(errors) { const uniqueErrors = new Map(); for (const error of errors) { @@ -292,59 +357,81 @@ export class DictionaryImportController { fragment.appendChild(div); } - this._errorContainer.appendChild(fragment); - this._errorContainer.hidden = false; + const errorContainer = /** @type {HTMLElement} */ (this._errorContainer); + errorContainer.appendChild(fragment); + errorContainer.hidden = false; } + /** */ _hideErrors() { - this._errorContainer.textContent = ''; - this._errorContainer.hidden = true; + const errorContainer = /** @type {HTMLElement} */ (this._errorContainer); + errorContainer.textContent = ''; + errorContainer.hidden = true; } + /** + * @param {File} file + * @returns {Promise<ArrayBuffer>} + */ _readFile(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); - reader.onload = () => resolve(reader.result); + reader.onload = () => resolve(/** @type {ArrayBuffer} */ (reader.result)); reader.onerror = () => reject(reader.error); reader.readAsArrayBuffer(file); }); } + /** + * @param {Error} error + * @returns {string} + */ _errorToString(error) { - error = (typeof error.toString === 'function' ? error.toString() : `${error}`); + const errorMessage = error.toString(); for (const [match, newErrorString] of this._errorToStringOverrides) { - if (error.includes(match)) { + if (errorMessage.includes(match)) { return newErrorString; } } - return error; + return errorMessage; } + /** + * @param {boolean} value + */ _setModifying(value) { this._modifying = value; this._setButtonsEnabled(!value); } + /** + * @param {boolean} value + */ _setButtonsEnabled(value) { value = !value; - for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) { + for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.dictionary-database-mutating-input'))) { node.disabled = value; } } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<Error[]>} + */ async _modifyGlobalSettings(targets) { const results = await this._settingsController.modifyGlobalSettings(targets); const errors = []; for (const {error} of results) { if (typeof error !== 'undefined') { - errors.push(deserializeError(error)); + errors.push(ExtensionError.deserialize(error)); } } return errors; } + /** */ _triggerStorageChanged() { yomitan.trigger('storageChanged'); } diff --git a/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js b/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js index 4f3ed569..d36d965a 100644 --- a/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js +++ b/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js @@ -22,24 +22,36 @@ import {yomitan} from '../../yomitan.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; export class ExtensionKeyboardShortcutController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?HTMLButtonElement} */ this._resetButton = null; + /** @type {?HTMLButtonElement} */ this._clearButton = null; + /** @type {?HTMLElement} */ this._listContainer = null; + /** @type {HotkeyUtil} */ this._hotkeyUtil = new HotkeyUtil(); + /** @type {?import('environment').OperatingSystem} */ this._os = null; + /** @type {ExtensionKeyboardShortcutHotkeyEntry[]} */ this._entries = []; } + /** @type {HotkeyUtil} */ get hotkeyUtil() { return this._hotkeyUtil; } + /** */ async prepare() { - this._resetButton = document.querySelector('#extension-hotkey-list-reset-all'); - this._clearButton = document.querySelector('#extension-hotkey-list-clear-all'); - this._listContainer = document.querySelector('#extension-hotkey-list'); + this._resetButton = /** @type {HTMLButtonElement} */ (document.querySelector('#extension-hotkey-list-reset-all')); + this._clearButton = /** @type {HTMLButtonElement} */ (document.querySelector('#extension-hotkey-list-clear-all')); + this._listContainer = /** @type {HTMLElement} */ (document.querySelector('#extension-hotkey-list')); const canResetCommands = this.canResetCommands(); const canModifyCommands = this.canModifyCommands(); @@ -61,10 +73,16 @@ export class ExtensionKeyboardShortcutController { this._setupCommands(commands); } + /** + * @param {string} name + * @returns {Promise<{key: ?string, modifiers: import('input').Modifier[]}>} + */ async resetCommand(name) { await this._resetCommand(name); + /** @type {?string} */ let key = null; + /** @type {import('input').Modifier[]} */ let modifiers = []; const commands = await this._getCommands(); @@ -78,32 +96,60 @@ export class ExtensionKeyboardShortcutController { return {key, modifiers}; } + /** + * @param {string} name + * @param {?string} key + * @param {import('input').Modifier[]} modifiers + */ async updateCommand(name, key, modifiers) { // Firefox-only; uses Promise API const shortcut = this._hotkeyUtil.convertInputToCommand(key, modifiers); - return await chrome.commands.update({name, shortcut}); + await browser.commands.update({name, shortcut}); } + /** + * @returns {boolean} + */ canResetCommands() { - return isObject(chrome.commands) && typeof chrome.commands.reset === 'function'; + return ( + typeof browser === 'object' && browser !== null && + typeof browser.commands === 'object' && browser.commands !== null && + typeof browser.commands.reset === 'function' + ); } + /** + * @returns {boolean} + */ canModifyCommands() { - return isObject(chrome.commands) && typeof chrome.commands.update === 'function'; + return ( + typeof browser === 'object' && browser !== null && + typeof browser.commands === 'object' && browser.commands !== null && + typeof browser.commands.update === 'function' + ); } // Add + /** + * @param {MouseEvent} e + */ _onResetClick(e) { e.preventDefault(); this._resetAllCommands(); } + /** + * @param {MouseEvent} e + */ _onClearClick(e) { e.preventDefault(); this._clearAllCommands(); } + /** + * @returns {Promise<chrome.commands.Command[]>} + */ _getCommands() { return new Promise((resolve, reject) => { if (!(isObject(chrome.commands) && typeof chrome.commands.getAll === 'function')) { @@ -122,6 +168,9 @@ export class ExtensionKeyboardShortcutController { }); } + /** + * @param {chrome.commands.Command[]} commands + */ _setupCommands(commands) { for (const entry of this._entries) { entry.cleanup(); @@ -131,7 +180,7 @@ export class ExtensionKeyboardShortcutController { const fragment = document.createDocumentFragment(); for (const {name, description, shortcut} of commands) { - if (name.startsWith('_')) { continue; } + if (typeof name !== 'string' || name.startsWith('_')) { continue; } const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut); @@ -143,10 +192,12 @@ export class ExtensionKeyboardShortcutController { this._entries.push(entry); } - this._listContainer.textContent = ''; - this._listContainer.appendChild(fragment); + const listContainer = /** @type {HTMLElement} */ (this._listContainer); + listContainer.textContent = ''; + listContainer.appendChild(fragment); } + /** */ async _resetAllCommands() { if (!this.canModifyCommands()) { return; } @@ -154,7 +205,7 @@ export class ExtensionKeyboardShortcutController { const promises = []; for (const {name} of commands) { - if (name.startsWith('_')) { continue; } + if (typeof name !== 'string' || name.startsWith('_')) { continue; } promises.push(this._resetCommand(name)); } @@ -164,6 +215,7 @@ export class ExtensionKeyboardShortcutController { this._setupCommands(commands); } + /** */ async _clearAllCommands() { if (!this.canModifyCommands()) { return; } @@ -171,7 +223,7 @@ export class ExtensionKeyboardShortcutController { const promises = []; for (const {name} of commands) { - if (name.startsWith('_')) { continue; } + if (typeof name !== 'string' || name.startsWith('_')) { continue; } promises.push(this.updateCommand(name, null, [])); } @@ -181,31 +233,55 @@ export class ExtensionKeyboardShortcutController { this._setupCommands(commands); } + /** + * @param {string} name + */ async _resetCommand(name) { // Firefox-only; uses Promise API - return await chrome.commands.reset(name); + await browser.commands.reset(name); } } class ExtensionKeyboardShortcutHotkeyEntry { + /** + * @param {ExtensionKeyboardShortcutController} parent + * @param {Element} node + * @param {string} name + * @param {string|undefined} description + * @param {?string} key + * @param {import('input').Modifier[]} modifiers + * @param {?import('environment').OperatingSystem} os + */ constructor(parent, node, name, description, key, modifiers, os) { + /** @type {ExtensionKeyboardShortcutController} */ this._parent = parent; + /** @type {Element} */ this._node = node; + /** @type {string} */ this._name = name; + /** @type {string|undefined} */ this._description = description; + /** @type {?string} */ this._key = key; + /** @type {import('input').Modifier[]} */ this._modifiers = modifiers; + /** @type {?import('environment').OperatingSystem} */ this._os = os; + /** @type {?HTMLInputElement} */ this._input = null; + /** @type {?KeyboardMouseInputField} */ this._inputField = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** */ prepare() { - this._node.querySelector('.settings-item-label').textContent = this._description || this._name; + const label = /** @type {HTMLElement} */ (this._node.querySelector('.settings-item-label')); + label.textContent = this._description || this._name; - const button = this._node.querySelector('.extension-hotkey-list-item-button'); - const input = this._node.querySelector('input'); + const button = /** @type {HTMLButtonElement} */ (this._node.querySelector('.extension-hotkey-list-item-button')); + const input = /** @type {HTMLInputElement} */ (this._node.querySelector('input')); this._input = input; @@ -222,6 +298,7 @@ class ExtensionKeyboardShortcutHotkeyEntry { } } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._node.parentNode !== null) { @@ -235,15 +312,22 @@ class ExtensionKeyboardShortcutHotkeyEntry { // Private + /** + * @param {import('keyboard-mouse-input-field').ChangeEvent} e + */ _onInputFieldChange(e) { const {key, modifiers} = e; this._tryUpdateInput(key, modifiers, false); } + /** */ _onInputFieldBlur() { this._updateInput(); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'clearInput': @@ -255,11 +339,19 @@ class ExtensionKeyboardShortcutHotkeyEntry { } } + /** */ _updateInput() { - this._inputField.setInput(this._key, this._modifiers); - delete this._input.dataset.invalid; + /** @type {KeyboardMouseInputField} */ (this._inputField).setInput(this._key, this._modifiers); + if (this._input !== null) { + delete this._input.dataset.invalid; + } } + /** + * @param {?string} key + * @param {import('input').Modifier[]} modifiers + * @param {boolean} updateInput + */ async _tryUpdateInput(key, modifiers, updateInput) { let okay = (key === null ? (modifiers.length === 0) : (modifiers.length !== 0)); if (okay) { @@ -273,9 +365,13 @@ class ExtensionKeyboardShortcutHotkeyEntry { if (okay) { this._key = key; this._modifiers = modifiers; - delete this._input.dataset.invalid; + if (this._input !== null) { + delete this._input.dataset.invalid; + } } else { - this._input.dataset.invalid = 'true'; + if (this._input !== null) { + this._input.dataset.invalid = 'true'; + } } if (updateInput) { @@ -283,6 +379,7 @@ class ExtensionKeyboardShortcutHotkeyEntry { } } + /** */ async _resetInput() { const {key, modifiers} = await this._parent.resetCommand(this._name); this._key = key; diff --git a/ext/js/pages/settings/generic-setting-controller.js b/ext/js/pages/settings/generic-setting-controller.js index c4104874..8666614b 100644 --- a/ext/js/pages/settings/generic-setting-controller.js +++ b/ext/js/pages/settings/generic-setting-controller.js @@ -16,14 +16,20 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, isObject} from '../../core.js'; +import {ExtensionError} from '../../core/extension-error.js'; import {DocumentUtil} from '../../dom/document-util.js'; import {DOMDataBinder} from '../../dom/dom-data-binder.js'; export class GenericSettingController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('settings-modifications').OptionsScopeType} */ this._defaultScope = 'profile'; + /** @type {DOMDataBinder<import('generic-setting-controller').ElementMetadata>} */ this._dataBinder = new DOMDataBinder({ selector: '[data-setting]', createElementMetadata: this._createElementMetadata.bind(this), @@ -31,7 +37,8 @@ export class GenericSettingController { getValues: this._getValues.bind(this), setValues: this._setValues.bind(this) }); - this._transforms = new Map([ + /** @type {Map<import('generic-setting-controller').TransformType, import('generic-setting-controller').TransformFunction>} */ + this._transforms = new Map(/** @type {[key: import('generic-setting-controller').TransformType, value: import('generic-setting-controller').TransformFunction][]} */ ([ ['setAttribute', this._setAttribute.bind(this)], ['setVisibility', this._setVisibility.bind(this)], ['splitTags', this._splitTags.bind(this)], @@ -40,41 +47,49 @@ export class GenericSettingController { ['toBoolean', this._toBoolean.bind(this)], ['toString', this._toString.bind(this)], ['conditionalConvert', this._conditionalConvert.bind(this)] - ]); + ])); } + /** */ async prepare() { this._dataBinder.observe(document.body); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); } + /** */ async refresh() { await this._dataBinder.refresh(); } // Private + /** */ _onOptionsChanged() { this._dataBinder.refresh(); } + /** + * @param {Element} element + * @returns {import('generic-setting-controller').ElementMetadata|undefined} + */ _createElementMetadata(element) { - const {dataset: {setting: path, scope, transform: transformRaw}} = element; - let transforms; - if (typeof transformRaw === 'string') { - transforms = JSON.parse(transformRaw); - if (!Array.isArray(transforms)) { transforms = [transforms]; } - } else { - transforms = []; - } + if (!(element instanceof HTMLElement)) { return void 0; } + const {setting: path, scope, transform: transformRaw} = element.dataset; + if (typeof path !== 'string') { return void 0; } + const scope2 = this._normalizeScope(scope); return { path, - scope, - transforms, + scope: scope2 !== null ? scope2 : this._defaultScope, + transforms: this._getTransformDataArray(transformRaw), transformRaw }; } + /** + * @param {import('generic-setting-controller').ElementMetadata} metadata1 + * @param {import('generic-setting-controller').ElementMetadata} metadata2 + * @returns {boolean} + */ _compareElementMetadata(metadata1, metadata2) { return ( metadata1.path === metadata2.path && @@ -83,45 +98,71 @@ export class GenericSettingController { ); } + /** + * @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets + * @returns {Promise<import('dom-data-binder').TaskResult[]>} + */ async _getValues(targets) { const defaultScope = this._defaultScope; + /** @type {import('settings-modifications').ScopedRead[]} */ const settingsTargets = []; for (const {metadata: {path, scope}} of targets) { + /** @type {import('settings-modifications').ScopedRead} */ const target = { path, - scope: scope || defaultScope + scope: typeof scope === 'string' ? scope : defaultScope, + optionsContext: null }; settingsTargets.push(target); } return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets); } + /** + * @param {import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets + * @returns {Promise<import('dom-data-binder').TaskResult[]>} + */ async _setValues(targets) { const defaultScope = this._defaultScope; + /** @type {import('settings-modifications').ScopedModification[]} */ const settingsTargets = []; for (const {metadata: {path, scope, transforms}, value, element} of targets) { const transformedValue = this._applyTransforms(value, transforms, 'pre', element); + /** @type {import('settings-modifications').ScopedModification} */ const target = { path, - scope: scope || defaultScope, + scope: typeof scope === 'string' ? scope : defaultScope, action: 'set', - value: transformedValue + value: transformedValue, + optionsContext: null }; settingsTargets.push(target); } return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets); } + /** + * @param {import('settings-controller').ModifyResult[]} values + * @param {import('dom-data-binder').GetValuesDetails<import('generic-setting-controller').ElementMetadata>[]|import('dom-data-binder').SetValuesDetails<import('generic-setting-controller').ElementMetadata>[]} targets + * @returns {import('dom-data-binder').TaskResult[]} + */ _transformResults(values, targets) { return values.map((value, i) => { const error = value.error; - if (error) { return deserializeError(error); } + if (error) { return {error: ExtensionError.deserialize(error)}; } const {metadata: {transforms}, element} = targets[i]; const result = this._applyTransforms(value.result, transforms, 'post', element); return {result}; }); } + /** + * @param {unknown} value + * @param {import('generic-setting-controller').TransformData[]} transforms + * @param {import('generic-setting-controller').TransformStep} step + * @param {Element} element + * @returns {unknown} + */ _applyTransforms(value, transforms, step, element) { for (const transform of transforms) { const transformStep = transform.step; @@ -135,6 +176,11 @@ export class GenericSettingController { return value; } + /** + * @param {?Node} node + * @param {number} ancestorDistance + * @returns {?Node} + */ _getAncestor(node, ancestorDistance) { if (ancestorDistance < 0) { return document.documentElement; @@ -145,6 +191,12 @@ export class GenericSettingController { return node; } + /** + * @param {?Node} node + * @param {number|undefined} ancestorDistance + * @param {string|undefined} selector + * @returns {?Node} + */ _getRelativeElement(node, ancestorDistance, selector) { const selectorRoot = ( typeof ancestorDistance === 'number' ? @@ -154,12 +206,17 @@ export class GenericSettingController { if (selectorRoot === null) { return null; } return ( - typeof selector === 'string' ? + typeof selector === 'string' && (selectorRoot instanceof Element || selectorRoot instanceof Document) ? selectorRoot.querySelector(selector) : (selectorRoot === document ? document.documentElement : selectorRoot) ); } + /** + * @param {import('generic-setting-controller').OperationData} operationData + * @param {unknown} lhs + * @returns {unknown} + */ _evaluateSimpleOperation(operationData, lhs) { const {op: operation, value: rhs} = operationData; switch (operation) { @@ -167,18 +224,18 @@ export class GenericSettingController { case '!!': return !!lhs; case '===': return lhs === rhs; case '!==': return lhs !== rhs; - case '>=': return lhs >= rhs; - case '<=': return lhs <= rhs; - case '>': return lhs > rhs; - case '<': return lhs < rhs; + case '>=': return /** @type {number} */ (lhs) >= /** @type {number} */ (rhs); + case '<=': return /** @type {number} */ (lhs) <= /** @type {number} */ (rhs); + case '>': return /** @type {number} */ (lhs) > /** @type {number} */ (rhs); + case '<': return /** @type {number} */ (lhs) < /** @type {number} */ (rhs); case '&&': - for (const operationData2 of rhs) { + for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) { const result = this._evaluateSimpleOperation(operationData2, lhs); if (!result) { return result; } } return true; case '||': - for (const operationData2 of rhs) { + for (const operationData2 of /** @type {import('generic-setting-controller').OperationData[]} */ (rhs)) { const result = this._evaluateSimpleOperation(operationData2, lhs); if (result) { return result; } } @@ -188,48 +245,112 @@ export class GenericSettingController { } } + /** + * @param {string|undefined} value + * @returns {?import('settings-modifications').OptionsScopeType} + */ + _normalizeScope(value) { + switch (value) { + case 'profile': + case 'global': + return value; + default: + return null; + } + } + + /** + * @param {string|undefined} transformRaw + * @returns {import('generic-setting-controller').TransformData[]} + */ + _getTransformDataArray(transformRaw) { + if (typeof transformRaw === 'string') { + const transforms = JSON.parse(transformRaw); + return Array.isArray(transforms) ? transforms : [transforms]; + } + return []; + } + // Transforms + /** + * @param {unknown} value + * @param {import('generic-setting-controller').SetAttributeTransformData} data + * @param {Element} element + * @returns {unknown} + */ _setAttribute(value, data, element) { const {ancestorDistance, selector, attribute} = data; const relativeElement = this._getRelativeElement(element, ancestorDistance, selector); - if (relativeElement !== null) { + if (relativeElement !== null && relativeElement instanceof Element) { relativeElement.setAttribute(attribute, `${value}`); } return value; } + /** + * @param {unknown} value + * @param {import('generic-setting-controller').SetVisibilityTransformData} data + * @param {Element} element + * @returns {unknown} + */ _setVisibility(value, data, element) { const {ancestorDistance, selector, condition} = data; const relativeElement = this._getRelativeElement(element, ancestorDistance, selector); - if (relativeElement !== null) { + if (relativeElement !== null && relativeElement instanceof HTMLElement) { relativeElement.hidden = !this._evaluateSimpleOperation(condition, value); } return value; } + /** + * @param {unknown} value + * @returns {string[]} + */ _splitTags(value) { return `${value}`.split(/[,; ]+/).filter((v) => !!v); } + /** + * @param {unknown} value + * @returns {string} + */ _joinTags(value) { - return value.join(' '); + return Array.isArray(value) ? value.join(' ') : ''; } + /** + * @param {unknown} value + * @param {import('generic-setting-controller').ToNumberConstraintsTransformData} data + * @returns {number} + */ _toNumber(value, data) { - let {constraints} = data; - if (!isObject(constraints)) { constraints = {}; } - return DocumentUtil.convertElementValueToNumber(value, constraints); + /** @type {import('document-util').ToNumberConstraints} */ + const constraints = typeof data.constraints === 'object' && data.constraints !== null ? data.constraints : {}; + return typeof value === 'string' ? DocumentUtil.convertElementValueToNumber(value, constraints) : 0; } + /** + * @param {string} value + * @returns {boolean} + */ _toBoolean(value) { return (value === 'true'); } + /** + * @param {unknown} value + * @returns {string} + */ _toString(value) { return `${value}`; } + /** + * @param {unknown} value + * @param {import('generic-setting-controller').ConditionalConvertTransformData} data + * @returns {unknown} + */ _conditionalConvert(value, data) { const {cases} = data; if (Array.isArray(cases)) { diff --git a/ext/js/pages/settings/keyboard-mouse-input-field.js b/ext/js/pages/settings/keyboard-mouse-input-field.js index aee01a36..f0a53f1a 100644 --- a/ext/js/pages/settings/keyboard-mouse-input-field.js +++ b/ext/js/pages/settings/keyboard-mouse-input-field.js @@ -20,31 +20,58 @@ import {EventDispatcher, EventListenerCollection} from '../../core.js'; import {DocumentUtil} from '../../dom/document-util.js'; import {HotkeyUtil} from '../../input/hotkey-util.js'; +/** + * @augments EventDispatcher<import('keyboard-mouse-input-field').EventType> + */ export class KeyboardMouseInputField extends EventDispatcher { + /** + * @param {HTMLInputElement} inputNode + * @param {?HTMLButtonElement} mouseButton + * @param {?import('environment').OperatingSystem} os + * @param {?(pointerType: string) => boolean} [isPointerTypeSupported] + */ constructor(inputNode, mouseButton, os, isPointerTypeSupported=null) { super(); + /** @type {HTMLInputElement} */ this._inputNode = inputNode; + /** @type {?HTMLButtonElement} */ this._mouseButton = mouseButton; + /** @type {?(pointerType: string) => boolean} */ this._isPointerTypeSupported = isPointerTypeSupported; + /** @type {HotkeyUtil} */ this._hotkeyUtil = new HotkeyUtil(os); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?string} */ this._key = null; + /** @type {import('input').Modifier[]} */ this._modifiers = []; + /** @type {Set<number>} */ this._penPointerIds = new Set(); + /** @type {boolean} */ this._mouseModifiersSupported = false; + /** @type {boolean} */ this._keySupported = false; } + /** @type {import('input').Modifier[]} */ get modifiers() { return this._modifiers; } + /** + * @param {?string} key + * @param {import('input').Modifier[]} modifiers + * @param {boolean} [mouseModifiersSupported] + * @param {boolean} [keySupported] + */ prepare(key, modifiers, mouseModifiersSupported=false, keySupported=false) { this.cleanup(); this._mouseModifiersSupported = mouseModifiersSupported; this._keySupported = keySupported; this.setInput(key, modifiers); + /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ const events = [ [this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false], [this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false] @@ -65,12 +92,17 @@ export class KeyboardMouseInputField extends EventDispatcher { } } + /** + * @param {?string} key + * @param {import('input').Modifier[]} modifiers + */ setInput(key, modifiers) { this._key = key; this._modifiers = this._sortModifiers(modifiers); this._updateDisplayString(); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); this._modifiers = []; @@ -80,21 +112,31 @@ export class KeyboardMouseInputField extends EventDispatcher { this._penPointerIds.clear(); } + /** */ clearInputs() { this._updateModifiers([], null); } // Private + /** + * @param {import('input').Modifier[]} modifiers + * @returns {import('input').Modifier[]} + */ _sortModifiers(modifiers) { return this._hotkeyUtil.sortModifiers(modifiers); } + /** */ _updateDisplayString() { const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers); this._inputNode.value = displayValue; } + /** + * @param {KeyboardEvent} e + * @returns {Set<import('input').ModifierKey>} + */ _getModifierKeys(e) { const modifiers = new Set(DocumentUtil.getActiveModifiers(e)); // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey @@ -115,6 +157,10 @@ export class KeyboardMouseInputField extends EventDispatcher { return modifiers; } + /** + * @param {string|undefined} keyName + * @returns {boolean} + */ _isModifierKey(keyName) { switch (keyName) { case 'AltLeft': @@ -133,9 +179,13 @@ export class KeyboardMouseInputField extends EventDispatcher { } } + /** + * @param {KeyboardEvent} e + */ _onModifierKeyDown(e) { e.preventDefault(); + /** @type {string|undefined} */ let key = e.code; if (key === 'Unidentified' || key === '') { key = void 0; } if (this._keySupported) { @@ -153,15 +203,24 @@ export class KeyboardMouseInputField extends EventDispatcher { } } + /** + * @param {KeyboardEvent} e + */ _onModifierKeyUp(e) { e.preventDefault(); } + /** + * @param {MouseEvent} e + */ _onMouseButtonMouseDown(e) { e.preventDefault(); this._addModifiers(DocumentUtil.getActiveButtons(e)); } + /** + * @param {PointerEvent} e + */ _onMouseButtonPointerDown(e) { if (!e.isPrimary) { return; } @@ -179,6 +238,9 @@ export class KeyboardMouseInputField extends EventDispatcher { this._addModifiers(DocumentUtil.getActiveButtons(e)); } + /** + * @param {PointerEvent} e + */ _onMouseButtonPointerOver(e) { const {pointerType, pointerId} = e; if (pointerType === 'pen') { @@ -186,23 +248,39 @@ export class KeyboardMouseInputField extends EventDispatcher { } } + /** + * @param {PointerEvent} e + */ _onMouseButtonPointerOut(e) { const {pointerId} = e; this._penPointerIds.delete(pointerId); } + /** + * @param {PointerEvent} e + */ _onMouseButtonPointerCancel(e) { this._onMouseButtonPointerOut(e); } + /** + * @param {MouseEvent} e + */ _onMouseButtonMouseUp(e) { e.preventDefault(); } + /** + * @param {MouseEvent} e + */ _onMouseButtonContextMenu(e) { e.preventDefault(); } + /** + * @param {Iterable<import('input').Modifier>} newModifiers + * @param {?string} [newKey] + */ _addModifiers(newModifiers, newKey) { const modifiers = new Set(this._modifiers); for (const modifier of newModifiers) { @@ -211,6 +289,10 @@ export class KeyboardMouseInputField extends EventDispatcher { this._updateModifiers([...modifiers], newKey); } + /** + * @param {import('input').Modifier[]} modifiers + * @param {?string} [newKey] + */ _updateModifiers(modifiers, newKey) { modifiers = this._sortModifiers(modifiers); @@ -226,10 +308,18 @@ export class KeyboardMouseInputField extends EventDispatcher { this._updateDisplayString(); if (changed) { - this.trigger('change', {modifiers: this._modifiers, key: this._key}); + /** @type {import('keyboard-mouse-input-field').ChangeEvent} */ + const event = {modifiers: this._modifiers, key: this._key}; + this.trigger('change', event); } } + /** + * @template T + * @param {T[]} array1 + * @param {T[]} array2 + * @returns {boolean} + */ _areArraysEqual(array1, array2) { const length = array1.length; if (length !== array2.length) { return false; } diff --git a/ext/js/pages/settings/keyboard-shortcuts-controller.js b/ext/js/pages/settings/keyboard-shortcuts-controller.js index e7ad4d15..ad16b0e9 100644 --- a/ext/js/pages/settings/keyboard-shortcuts-controller.js +++ b/ext/js/pages/settings/keyboard-shortcuts-controller.js @@ -23,16 +23,29 @@ import {yomitan} from '../../yomitan.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; export class KeyboardShortcutController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {KeyboardShortcutHotkeyEntry[]} */ this._entries = []; + /** @type {?import('environment').OperatingSystem} */ this._os = null; + /** @type {?HTMLButtonElement} */ this._addButton = null; + /** @type {?HTMLButtonElement} */ this._resetButton = null; + /** @type {?HTMLElement} */ this._listContainer = null; + /** @type {?HTMLElement} */ this._emptyIndicator = null; + /** @type {Intl.Collator} */ this._stringComparer = new Intl.Collator('en-US'); // Invariant locale + /** @type {?HTMLElement} */ this._scrollContainer = null; + /** @type {Map<string, import('keyboard-shortcut-controller').ActionDetails>} */ this._actionDetails = new Map([ ['', {scopes: new Set()}], ['close', {scopes: new Set(['popup', 'search'])}], @@ -58,19 +71,21 @@ export class KeyboardShortcutController { ]); } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._settingsController; } + /** */ async prepare() { const {platform: {os}} = await yomitan.api.getEnvironmentInfo(); this._os = os; - this._addButton = document.querySelector('#hotkey-list-add'); - this._resetButton = document.querySelector('#hotkey-list-reset'); - this._listContainer = document.querySelector('#hotkey-list'); - this._emptyIndicator = document.querySelector('#hotkey-list-empty'); - this._scrollContainer = document.querySelector('#keyboard-shortcuts-modal .modal-body'); + this._addButton = /** @type {HTMLButtonElement} */ (document.querySelector('#hotkey-list-add')); + this._resetButton = /** @type {HTMLButtonElement} */ (document.querySelector('#hotkey-list-reset')); + this._listContainer = /** @type {HTMLElement} */ (document.querySelector('#hotkey-list')); + this._emptyIndicator = /** @type {HTMLElement} */ (document.querySelector('#hotkey-list-empty')); + this._scrollContainer = /** @type {HTMLElement} */ (document.querySelector('#keyboard-shortcuts-modal .modal-body')); this._addButton.addEventListener('click', this._onAddClick.bind(this)); this._resetButton.addEventListener('click', this._onResetClick.bind(this)); @@ -79,6 +94,9 @@ export class KeyboardShortcutController { await this._updateOptions(); } + /** + * @param {import('settings').InputsHotkeyOptions} terminationCharacterEntry + */ async addEntry(terminationCharacterEntry) { const options = await this._settingsController.getOptions(); const {inputs: {hotkeys}} = options; @@ -92,9 +110,14 @@ export class KeyboardShortcutController { }]); await this._updateOptions(); - this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight; + const scrollContainer = /** @type {HTMLElement} */ (this._scrollContainer); + scrollContainer.scrollTop = scrollContainer.scrollHeight; } + /** + * @param {number} index + * @returns {Promise<boolean>} + */ async deleteEntry(index) { const options = await this._settingsController.getOptions(); const {inputs: {hotkeys}} = options; @@ -113,55 +136,79 @@ export class KeyboardShortcutController { return true; } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async modifyProfileSettings(targets) { return await this._settingsController.modifyProfileSettings(targets); } + /** + * @returns {Promise<import('settings').InputsHotkeyOptions[]>} + */ async getDefaultHotkeys() { const defaultOptions = await this._settingsController.getDefaultOptions(); return defaultOptions.profiles[0].options.inputs.hotkeys; } + /** + * @param {string} action + * @returns {import('keyboard-shortcut-controller').ActionDetails|undefined} + */ getActionDetails(action) { return this._actionDetails.get(action); } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { for (const entry of this._entries) { entry.cleanup(); } this._entries = []; + const os = /** @type {import('environment').OperatingSystem} */ (this._os); const {inputs: {hotkeys}} = options; const fragment = document.createDocumentFragment(); for (let i = 0, ii = hotkeys.length; i < ii; ++i) { const hotkeyEntry = hotkeys[i]; - const node = this._settingsController.instantiateTemplate('hotkey-list-item'); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('hotkey-list-item')); fragment.appendChild(node); - const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, this._os, this._stringComparer); + const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, os, this._stringComparer); this._entries.push(entry); entry.prepare(); } - this._listContainer.appendChild(fragment); - this._listContainer.hidden = (hotkeys.length === 0); - this._emptyIndicator.hidden = (hotkeys.length !== 0); + const listContainer = /** @type {HTMLElement} */ (this._listContainer); + listContainer.appendChild(fragment); + listContainer.hidden = (hotkeys.length === 0); + /** @type {HTMLElement} */ (this._emptyIndicator).hidden = (hotkeys.length !== 0); } + /** + * @param {MouseEvent} e + */ _onAddClick(e) { e.preventDefault(); this._addNewEntry(); } + /** + * @param {MouseEvent} e + */ _onResetClick(e) { e.preventDefault(); this._reset(); } + /** */ async _addNewEntry() { + /** @type {import('settings').InputsHotkeyOptions} */ const newEntry = { action: '', argument: '', @@ -170,14 +217,17 @@ export class KeyboardShortcutController { scopes: ['popup', 'search'], enabled: true }; - return await this.addEntry(newEntry); + await this.addEntry(newEntry); } + /** */ async _updateOptions() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** */ async _reset() { const value = await this.getDefaultHotkeys(); await this._settingsController.setProfileSetting('inputs.hotkeys', value); @@ -186,34 +236,59 @@ export class KeyboardShortcutController { } class KeyboardShortcutHotkeyEntry { + /** + * @param {KeyboardShortcutController} parent + * @param {import('settings').InputsHotkeyOptions} data + * @param {number} index + * @param {HTMLElement} node + * @param {import('environment').OperatingSystem} os + * @param {Intl.Collator} stringComparer + */ constructor(parent, data, index, node, os, stringComparer) { + /** @type {KeyboardShortcutController} */ this._parent = parent; + /** @type {import('settings').InputsHotkeyOptions} */ this._data = data; + /** @type {number} */ this._index = index; + /** @type {HTMLElement} */ this._node = node; + /** @type {import('environment').OperatingSystem} */ this._os = os; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?KeyboardMouseInputField} */ this._inputField = null; + /** @type {?HTMLSelectElement} */ this._actionSelect = null; + /** @type {string} */ this._basePath = `inputs.hotkeys[${this._index}]`; + /** @type {Intl.Collator} */ this._stringComparer = stringComparer; + /** @type {?HTMLButtonElement} */ this._enabledButton = null; + /** @type {?import('../../dom/popup-menu.js').PopupMenu} */ this._scopeMenu = null; + /** @type {EventListenerCollection} */ this._scopeMenuEventListeners = new EventListenerCollection(); + /** @type {?HTMLElement} */ this._argumentContainer = null; + /** @type {?HTMLInputElement} */ this._argumentInput = null; + /** @type {EventListenerCollection} */ this._argumentEventListeners = new EventListenerCollection(); } + /** */ prepare() { const node = this._node; - const menuButton = node.querySelector('.hotkey-list-item-button'); - const input = node.querySelector('.hotkey-list-item-input'); - const action = node.querySelector('.hotkey-list-item-action'); - const enabledToggle = node.querySelector('.hotkey-list-item-enabled'); - const scopesButton = node.querySelector('.hotkey-list-item-scopes-button'); - const enabledButton = node.querySelector('.hotkey-list-item-enabled-button'); + const menuButton = /** @type {HTMLButtonElement} */ (node.querySelector('.hotkey-list-item-button')); + const input = /** @type {HTMLInputElement} */ (node.querySelector('.hotkey-list-item-input')); + const action = /** @type {HTMLSelectElement} */ (node.querySelector('.hotkey-list-item-action')); + const enabledToggle = /** @type {HTMLInputElement} */ (node.querySelector('.hotkey-list-item-enabled')); + const scopesButton = /** @type {HTMLButtonElement} */ (node.querySelector('.hotkey-list-item-scopes-button')); + const enabledButton = /** @type {HTMLButtonElement} */ (node.querySelector('.hotkey-list-item-enabled-button')); this._actionSelect = action; this._enabledButton = enabledButton; @@ -238,9 +313,10 @@ class KeyboardShortcutHotkeyEntry { this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this)); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); - this._inputField.cleanup(); + /** @type {KeyboardMouseInputField} */ (this._inputField).cleanup(); this._clearScopeMenu(); this._clearArgumentEventListeners(); if (this._node.parentNode !== null) { @@ -250,11 +326,14 @@ class KeyboardShortcutHotkeyEntry { // Private + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const {action} = this._data; const {menu} = e.detail; - const resetArgument = menu.bodyNode.querySelector('.popup-menu-item[data-menu-action="resetArgument"]'); + const resetArgument = /** @type {HTMLElement} */ (menu.bodyNode.querySelector('.popup-menu-item[data-menu-action="resetArgument"]')); const details = this._parent.getActionDetails(action); const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0; @@ -262,13 +341,16 @@ class KeyboardShortcutHotkeyEntry { resetArgument.hidden = (typeof argumentDetails === 'undefined'); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'delete': this._delete(); break; case 'clearInputs': - this._inputField.clearInputs(); + /** @type {KeyboardMouseInputField} */ (this._inputField).clearInputs(); break; case 'resetInput': this._resetInput(); @@ -279,10 +361,13 @@ class KeyboardShortcutHotkeyEntry { } } + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onScopesMenuOpen(e) { const {menu} = e.detail; const validScopes = this._getValidScopesForAction(this._data.action); - if (validScopes.size === 0) { + if (validScopes === null || validScopes.size === 0) { menu.close(); return; } @@ -291,6 +376,9 @@ class KeyboardShortcutHotkeyEntry { this._updateDisplay(menu.containerNode); // Fix a animation issue due to changing checkbox values } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onScopesMenuClose(e) { const {menu, action} = e.detail; if (action === 'toggleScope') { @@ -302,24 +390,45 @@ class KeyboardShortcutHotkeyEntry { } } + /** + * @param {import('keyboard-mouse-input-field').ChangeEvent} details + */ _onInputFieldChange({key, modifiers}) { - this._setKeyAndModifiers(key, modifiers); + /** @type {import('input').ModifierKey[]} */ + const modifiers2 = []; + for (const modifier of modifiers) { + const modifier2 = DocumentUtil.normalizeModifierKey(modifier); + if (modifier2 === null) { continue; } + modifiers2.push(modifier2); + } + this._setKeyAndModifiers(key, modifiers2); } + /** + * @param {MouseEvent} e + */ _onScopeCheckboxChange(e) { - const node = e.currentTarget; - const {scope} = node.dataset; - if (typeof scope !== 'string') { return; } + const node = /** @type {HTMLInputElement} */ (e.currentTarget); + const scope = this._normalizeScope(node.dataset.scope); + if (scope === null) { return; } this._setScopeEnabled(scope, node.checked); } + /** + * @param {MouseEvent} e + */ _onActionSelectChange(e) { - const value = e.currentTarget.value; + const node = /** @type {HTMLSelectElement} */ (e.currentTarget); + const value = node.value; this._setAction(value); } + /** + * @param {string} template + * @param {Event} e + */ _onArgumentValueChange(template, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); let value = this._getArgumentInputValue(node); switch (template) { case 'hotkey-argument-move-offset': @@ -329,10 +438,15 @@ class KeyboardShortcutHotkeyEntry { this._setArgument(value); } + /** */ async _delete() { this._parent.deleteEntry(this._index); } + /** + * @param {?string} key + * @param {import('input').ModifierKey[]} modifiers + */ async _setKeyAndModifiers(key, modifiers) { this._data.key = key; this._data.modifiers = modifiers; @@ -350,6 +464,10 @@ class KeyboardShortcutHotkeyEntry { ]); } + /** + * @param {import('settings').InputsHotkeyScope} scope + * @param {boolean} enabled + */ async _setScopeEnabled(scope, enabled) { const scopes = this._data.scopes; const index = scopes.indexOf(scope); @@ -372,10 +490,15 @@ class KeyboardShortcutHotkeyEntry { this._updateScopesButton(); } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async _modifyProfileSettings(targets) { return await this._parent.settingsController.modifyProfileSettings(targets); } + /** */ async _resetInput() { const defaultHotkeys = await this._parent.getDefaultHotkeys(); const defaultValue = this._getDefaultKeyAndModifiers(defaultHotkeys, this._data.action); @@ -383,9 +506,10 @@ class KeyboardShortcutHotkeyEntry { const {key, modifiers} = defaultValue; await this._setKeyAndModifiers(key, modifiers); - this._inputField.setInput(key, modifiers); + /** @type {KeyboardMouseInputField} */ (this._inputField).setInput(key, modifiers); } + /** */ async _resetArgument() { const {action} = this._data; const details = this._parent.getActionDetails(action); @@ -395,6 +519,11 @@ class KeyboardShortcutHotkeyEntry { await this._setArgument(argumentDefault); } + /** + * @param {import('settings').InputsHotkeyOptions[]} defaultHotkeys + * @param {string} action + * @returns {?{modifiers: import('settings').InputsHotkeyModifier[], key: ?string}} + */ _getDefaultKeyAndModifiers(defaultHotkeys, action) { for (const {action: action2, key, modifiers} of defaultHotkeys) { if (action2 !== action) { continue; } @@ -403,16 +532,18 @@ class KeyboardShortcutHotkeyEntry { return null; } + /** + * @param {string} value + */ async _setAction(value) { const validScopesOld = this._getValidScopesForAction(this._data.action); const scopes = this._data.scopes; let details = this._parent.getActionDetails(value); - if (typeof details === 'undefined') { details = {}; } + if (typeof details === 'undefined') { details = {scopes: new Set()}; } - let validScopes = details.scopes; - if (typeof validScopes === 'undefined') { validScopes = new Set(); } + const validScopes = details.scopes; const {argument: argumentDetails} = details; let defaultArgument = typeof argumentDetails !== 'undefined' ? argumentDetails.default : ''; @@ -462,6 +593,9 @@ class KeyboardShortcutHotkeyEntry { this._updateActionArgument(); } + /** + * @param {string} value + */ async _setArgument(value) { this._data.argument = value; @@ -479,16 +613,24 @@ class KeyboardShortcutHotkeyEntry { }]); } + /** */ _updateScopesMenu() { if (this._scopeMenu === null) { return; } this._updateScopeMenuItems(this._scopeMenu); } + /** + * @param {string} action + * @returns {?Set<import('settings').InputsHotkeyScope>} + */ _getValidScopesForAction(action) { const details = this._parent.getActionDetails(action); return typeof details !== 'undefined' ? details.scopes : null; } + /** + * @param {import('../../dom/popup-menu.js').PopupMenu} menu + */ _updateScopeMenuItems(menu) { this._scopeMenuEventListeners.removeAllEventListeners(); @@ -496,14 +638,15 @@ class KeyboardShortcutHotkeyEntry { const validScopes = this._getValidScopesForAction(this._data.action); const bodyNode = menu.bodyNode; - const menuItems = bodyNode.querySelectorAll('.popup-menu-item'); + const menuItems = /** @type {NodeListOf<HTMLElement>} */ (bodyNode.querySelectorAll('.popup-menu-item')); for (const menuItem of menuItems) { if (menuItem.dataset.menuAction !== 'toggleScope') { continue; } - const {scope} = menuItem.dataset; + const scope = this._normalizeScope(menuItem.dataset.scope); + if (scope === null) { continue; } menuItem.hidden = !(validScopes === null || validScopes.has(scope)); - const checkbox = menuItem.querySelector('.hotkey-scope-checkbox'); + const checkbox = /** @type {HTMLInputElement} */ (menuItem.querySelector('.hotkey-scope-checkbox')); if (checkbox !== null) { checkbox.checked = scopes.includes(scope); this._scopeMenuEventListeners.addEventListener(checkbox, 'change', this._onScopeCheckboxChange.bind(this), false); @@ -511,16 +654,23 @@ class KeyboardShortcutHotkeyEntry { } } + /** */ _clearScopeMenu() { this._scopeMenuEventListeners.removeAllEventListeners(); this._scopeMenu = null; } + /** */ _updateScopesButton() { const {scopes} = this._data; - this._enabledButton.dataset.scopeCount = `${scopes.length}`; + if (this._enabledButton !== null) { + this._enabledButton.dataset.scopeCount = `${scopes.length}`; + } } + /** + * @param {HTMLElement} node + */ _updateDisplay(node) { const {style} = node; const {display} = style; @@ -529,49 +679,64 @@ class KeyboardShortcutHotkeyEntry { style.display = display; } + /** */ _updateActionArgument() { this._clearArgumentEventListeners(); const {action, argument} = this._data; const details = this._parent.getActionDetails(action); - const {argument: argumentDetails} = typeof details !== 'undefined' ? details : {}; + const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0; - this._argumentContainer.textContent = ''; + if (this._argumentContainer !== null) { + this._argumentContainer.textContent = ''; + } if (typeof argumentDetails !== 'undefined') { const {template} = argumentDetails; const node = this._parent.settingsController.instantiateTemplate(template); const inputSelector = '.hotkey-argument-input'; - const inputNode = node.matches(inputSelector) ? node : node.querySelector(inputSelector); + const inputNode = /** @type {HTMLInputElement} */ (node.matches(inputSelector) ? node : node.querySelector(inputSelector)); if (inputNode !== null) { this._setArgumentInputValue(inputNode, argument); this._argumentInput = inputNode; this._updateArgumentInputValidity(); this._argumentEventListeners.addEventListener(inputNode, 'change', this._onArgumentValueChange.bind(this, template), false); } - this._argumentContainer.appendChild(node); + if (this._argumentContainer !== null) { + this._argumentContainer.appendChild(node); + } } } + /** */ _clearArgumentEventListeners() { this._argumentEventListeners.removeAllEventListeners(); this._argumentInput = null; } + /** + * @param {HTMLInputElement} node + * @returns {string} + */ _getArgumentInputValue(node) { return node.value; } + /** + * @param {HTMLInputElement} node + * @param {string} value + */ _setArgumentInputValue(node, value) { node.value = value; } + /** */ async _updateArgumentInputValidity() { if (this._argumentInput === null) { return; } let okay = true; const {action, argument} = this._data; const details = this._parent.getActionDetails(action); - const {argument: argumentDetails} = typeof details !== 'undefined' ? details : {}; + const argumentDetails = typeof details !== 'undefined' ? details.argument : void 0; if (typeof argumentDetails !== 'undefined') { const {template} = argumentDetails; @@ -585,6 +750,10 @@ class KeyboardShortcutHotkeyEntry { this._argumentInput.dataset.invalid = `${!okay}`; } + /** + * @param {string} path + * @returns {Promise<boolean>} + */ async _isHotkeyArgumentSettingPathValid(path) { if (path.length === 0) { return true; } @@ -601,4 +770,19 @@ class KeyboardShortcutHotkeyEntry { } return false; } + + /** + * @param {string|undefined} value + * @returns {?import('settings').InputsHotkeyScope} + */ + _normalizeScope(value) { + switch (value) { + case 'popup': + case 'search': + case 'web': + return value; + default: + return null; + } + } } diff --git a/ext/js/pages/settings/mecab-controller.js b/ext/js/pages/settings/mecab-controller.js index a839fc21..4e2b02c6 100644 --- a/ext/js/pages/settings/mecab-controller.js +++ b/ext/js/pages/settings/mecab-controller.js @@ -19,48 +19,61 @@ import {yomitan} from '../../yomitan.js'; export class MecabController { - constructor(settingsController) { - this._settingsController = settingsController; + constructor() { + /** @type {?HTMLButtonElement} */ this._testButton = null; + /** @type {?HTMLElement} */ this._resultsContainer = null; + /** @type {boolean} */ this._testActive = false; } + /** */ prepare() { - this._testButton = document.querySelector('#test-mecab-button'); - this._resultsContainer = document.querySelector('#test-mecab-results'); + this._testButton = /** @type {HTMLButtonElement} */ (document.querySelector('#test-mecab-button')); + this._resultsContainer = /** @type {HTMLElement} */ (document.querySelector('#test-mecab-results')); this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false); } // Private + /** + * @param {MouseEvent} e + */ _onTestButtonClick(e) { e.preventDefault(); this._testMecab(); } + /** */ async _testMecab() { if (this._testActive) { return; } try { this._testActive = true; - this._testButton.disabled = true; - this._resultsContainer.textContent = ''; - this._resultsContainer.hidden = true; + const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer); + /** @type {HTMLButtonElement} */ (this._testButton).disabled = true; + resultsContainer.textContent = ''; + resultsContainer.hidden = true; await yomitan.api.testMecab(); this._setStatus('Connection was successful', false); } catch (e) { - this._setStatus(e.message, true); + this._setStatus(e instanceof Error ? e.message : `${e}`, true); } finally { this._testActive = false; - this._testButton.disabled = false; + /** @type {HTMLButtonElement} */ (this._testButton).disabled = false; } } + /** + * @param {string} message + * @param {boolean} isError + */ _setStatus(message, isError) { - this._resultsContainer.textContent = message; - this._resultsContainer.hidden = false; - this._resultsContainer.classList.toggle('danger-text', isError); + const resultsContainer = /** @type {HTMLElement} */ (this._resultsContainer); + resultsContainer.textContent = message; + resultsContainer.hidden = false; + resultsContainer.classList.toggle('danger-text', isError); } } diff --git a/ext/js/pages/settings/modal-controller.js b/ext/js/pages/settings/modal-controller.js index 517a19b3..852bdcc5 100644 --- a/ext/js/pages/settings/modal-controller.js +++ b/ext/js/pages/settings/modal-controller.js @@ -20,13 +20,16 @@ import {Modal} from './modal.js'; export class ModalController { constructor() { + /** @type {Modal[]} */ this._modals = []; + /** @type {Map<string|Element, Modal>} */ this._modalMap = new Map(); } + /** */ prepare() { const idSuffix = '-modal'; - for (const node of document.querySelectorAll('.modal')) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.modal'))) { let {id} = node; if (typeof id !== 'string') { continue; } @@ -42,11 +45,18 @@ export class ModalController { } } + /** + * @param {string|Element} nameOrNode + * @returns {?Modal} + */ getModal(nameOrNode) { const modal = this._modalMap.get(nameOrNode); return (typeof modal !== 'undefined' ? modal : null); } + /** + * @returns {?Modal} + */ getTopVisibleModal() { for (let i = this._modals.length - 1; i >= 0; --i) { const modal = this._modals[i]; diff --git a/ext/js/pages/settings/modal.js b/ext/js/pages/settings/modal.js index 4d1c098d..21a6e705 100644 --- a/ext/js/pages/settings/modal.js +++ b/ext/js/pages/settings/modal.js @@ -19,40 +19,55 @@ import {PanelElement} from '../../dom/panel-element.js'; export class Modal extends PanelElement { + /** + * @param {HTMLElement} node + */ constructor(node) { super({ node, closingAnimationDuration: 375 // Milliseconds; includes buffer }); + /** @type {?Element} */ this._contentNode = null; + /** @type {boolean} */ this._canCloseOnClick = false; } + /** */ prepare() { const node = this.node; this._contentNode = node.querySelector('.modal-content'); - let dimmerNode = node.querySelector('.modal-content-dimmer'); + let dimmerNode = /** @type {?HTMLElement} */ (node.querySelector('.modal-content-dimmer')); if (dimmerNode === null) { dimmerNode = node; } dimmerNode.addEventListener('mousedown', this._onModalContainerMouseDown.bind(this), false); dimmerNode.addEventListener('mouseup', this._onModalContainerMouseUp.bind(this), false); dimmerNode.addEventListener('click', this._onModalContainerClick.bind(this), false); - for (const actionNode of node.querySelectorAll('[data-modal-action]')) { + for (const actionNode of /** @type {NodeListOf<HTMLElement>} */ (node.querySelectorAll('[data-modal-action]'))) { actionNode.addEventListener('click', this._onActionNodeClick.bind(this), false); } } // Private + /** + * @param {MouseEvent} e + */ _onModalContainerMouseDown(e) { this._canCloseOnClick = (e.currentTarget === e.target); } + /** + * @param {MouseEvent} e + */ _onModalContainerMouseUp(e) { if (!this._canCloseOnClick) { return; } this._canCloseOnClick = (e.currentTarget === e.target); } + /** + * @param {MouseEvent} e + */ _onModalContainerClick(e) { if (!this._canCloseOnClick) { return; } this._canCloseOnClick = false; @@ -60,8 +75,12 @@ export class Modal extends PanelElement { this.setVisible(false); } + /** + * @param {MouseEvent} e + */ _onActionNodeClick(e) { - const {modalAction} = e.currentTarget.dataset; + const element = /** @type {HTMLElement} */ (e.currentTarget); + const {modalAction} = element.dataset; switch (modalAction) { case 'expand': this._setExpanded(true); @@ -72,6 +91,9 @@ export class Modal extends PanelElement { } } + /** + * @param {boolean} expanded + */ _setExpanded(expanded) { if (this._contentNode === null) { return; } this._contentNode.classList.toggle('modal-content-full', expanded); diff --git a/ext/js/pages/settings/nested-popups-controller.js b/ext/js/pages/settings/nested-popups-controller.js index b9621ef0..c01986ab 100644 --- a/ext/js/pages/settings/nested-popups-controller.js +++ b/ext/js/pages/settings/nested-popups-controller.js @@ -19,50 +19,79 @@ import {DocumentUtil} from '../../dom/document-util.js'; export class NestedPopupsController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {number} */ this._popupNestingMaxDepth = 0; + /** @type {?HTMLInputElement} */ + this._nestedPopupsEnabled = null; + /** @type {?HTMLInputElement} */ + this._nestedPopupsCount = null; + /** @type {?HTMLElement} */ + this._nestedPopupsEnabledMoreOptions = null; } + /** */ async prepare() { - this._nestedPopupsEnabled = document.querySelector('#nested-popups-enabled'); - this._nestedPopupsCount = document.querySelector('#nested-popups-count'); - this._nestedPopupsEnabledMoreOptions = document.querySelector('#nested-popups-enabled-more-options'); + this._nestedPopupsEnabled = /** @type {HTMLInputElement} */ (document.querySelector('#nested-popups-enabled')); + this._nestedPopupsCount = /** @type {HTMLInputElement} */ (document.querySelector('#nested-popups-count')); + this._nestedPopupsEnabledMoreOptions = /** @type {HTMLElement} */ (document.querySelector('#nested-popups-enabled-more-options')); const options = await this._settingsController.getOptions(); + const optionsContext = this._settingsController.getOptionsContext(); this._nestedPopupsEnabled.addEventListener('change', this._onNestedPopupsEnabledChange.bind(this), false); this._nestedPopupsCount.addEventListener('change', this._onNestedPopupsCountChange.bind(this), false); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - this._onOptionsChanged({options}); + this._onOptionsChanged({options, optionsContext}); } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { this._updatePopupNestingMaxDepth(options.scanning.popupNestingMaxDepth); } + /** + * @param {Event} e + */ _onNestedPopupsEnabledChange(e) { - const value = e.currentTarget.checked; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); + const value = node.checked; if (value && this._popupNestingMaxDepth > 0) { return; } this._setPopupNestingMaxDepth(value ? 1 : 0); } + /** + * @param {Event} e + */ _onNestedPopupsCountChange(e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); const value = Math.max(1, DocumentUtil.convertElementValueToNumber(node.value, node)); this._setPopupNestingMaxDepth(value); } + /** + * @param {number} value + */ _updatePopupNestingMaxDepth(value) { const enabled = (value > 0); this._popupNestingMaxDepth = value; - this._nestedPopupsEnabled.checked = enabled; - this._nestedPopupsCount.value = `${value}`; - this._nestedPopupsEnabledMoreOptions.hidden = !enabled; + /** @type {HTMLInputElement} */ (this._nestedPopupsEnabled).checked = enabled; + /** @type {HTMLInputElement} */ (this._nestedPopupsCount).value = `${value}`; + /** @type {HTMLElement} */ (this._nestedPopupsEnabledMoreOptions).hidden = !enabled; } + /** + * @param {number} value + */ async _setPopupNestingMaxDepth(value) { this._updatePopupNestingMaxDepth(value); await this._settingsController.setProfileSetting('scanning.popupNestingMaxDepth', value); diff --git a/ext/js/pages/settings/permissions-origin-controller.js b/ext/js/pages/settings/permissions-origin-controller.js index d234faa0..a4271f92 100644 --- a/ext/js/pages/settings/permissions-origin-controller.js +++ b/ext/js/pages/settings/permissions-origin-controller.js @@ -19,24 +19,36 @@ import {EventListenerCollection} from '../../core.js'; export class PermissionsOriginController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?HTMLElement} */ this._originContainer = null; + /** @type {?HTMLElement} */ this._originEmpty = null; + /** @type {?NodeListOf<HTMLInputElement>} */ this._originToggleNodes = null; + /** @type {?HTMLInputElement} */ this._addOriginInput = null; + /** @type {?HTMLElement} */ this._errorContainer = null; + /** @type {ChildNode[]} */ this._originContainerChildren = []; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** */ async prepare() { - this._originContainer = document.querySelector('#permissions-origin-list'); - this._originEmpty = document.querySelector('#permissions-origin-list-empty'); - this._originToggleNodes = document.querySelectorAll('.permissions-origin-toggle'); - this._addOriginInput = document.querySelector('#permissions-origin-new-input'); - this._errorContainer = document.querySelector('#permissions-origin-list-error'); - const addButton = document.querySelector('#permissions-origin-add'); + this._originContainer = /** @type {HTMLElement} */ (document.querySelector('#permissions-origin-list')); + this._originEmpty = /** @type {HTMLElement} */ (document.querySelector('#permissions-origin-list-empty')); + this._originToggleNodes = /** @type {NodeListOf<HTMLInputElement>} */ (document.querySelectorAll('.permissions-origin-toggle')); + this._addOriginInput = /** @type {HTMLInputElement} */ (document.querySelector('#permissions-origin-new-input')); + this._errorContainer = /** @type {HTMLElement} */ (document.querySelector('#permissions-origin-list-error')); + const addButton = /** @type {HTMLButtonElement} */ (document.querySelector('#permissions-origin-add')); for (const node of this._originToggleNodes) { node.addEventListener('change', this._onOriginToggleChange.bind(this), false); @@ -49,6 +61,9 @@ export class PermissionsOriginController { // Private + /** + * @param {import('settings-controller').PermissionsChangedEvent} details + */ _onPermissionsChanged({permissions}) { this._eventListeners.removeAllEventListeners(); for (const node of this._originContainerChildren) { @@ -57,9 +72,11 @@ export class PermissionsOriginController { } this._originContainerChildren = []; + /** @type {Set<string>} */ const originsSet = new Set(permissions.origins); - for (const node of this._originToggleNodes) { - node.checked = originsSet.has(node.dataset.origin); + for (const node of /** @type {NodeListOf<HTMLInputElement>} */ (this._originToggleNodes)) { + const {origin} = node.dataset; + node.checked = typeof origin === 'string' && originsSet.has(origin); } let any = false; @@ -67,60 +84,78 @@ export class PermissionsOriginController { '<all_urls>' ]); const fragment = document.createDocumentFragment(); - for (const origin of permissions.origins) { + for (const origin of originsSet) { if (excludeOrigins.has(origin)) { continue; } const node = this._settingsController.instantiateTemplateFragment('permissions-origin'); - const input = node.querySelector('.permissions-origin-input'); - const menuButton = node.querySelector('.permissions-origin-button'); + const input = /** @type {HTMLInputElement} */ (node.querySelector('.permissions-origin-input')); + const menuButton = /** @type {HTMLElement} */ (node.querySelector('.permissions-origin-button')); input.value = origin; this._eventListeners.addEventListener(menuButton, 'menuClose', this._onOriginMenuClose.bind(this, origin), false); this._originContainerChildren.push(...node.childNodes); fragment.appendChild(node); any = true; } - this._originContainer.insertBefore(fragment, this._originContainer.firstChild); - this._originEmpty.hidden = any; + const container = /** @type {HTMLElement} */ (this._originContainer); + container.insertBefore(fragment, container.firstChild); + /** @type {HTMLElement} */ (this._originEmpty).hidden = any; - this._errorContainer.hidden = true; + /** @type {HTMLElement} */ (this._errorContainer).hidden = true; } + /** + * @param {Event} e + */ _onOriginToggleChange(e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); const value = node.checked; node.checked = !value; const {origin} = node.dataset; + if (typeof origin !== 'string') { return; } this._setOriginPermissionEnabled(origin, value); } + /** + * @param {string} origin + */ _onOriginMenuClose(origin) { this._setOriginPermissionEnabled(origin, false); } + /** */ _onAddButtonClick() { this._addOrigin(); } + /** */ async _addOrigin() { - const origin = this._addOriginInput.value; + const input = /** @type {HTMLInputElement} */ (this._addOriginInput); + const origin = input.value; const added = await this._setOriginPermissionEnabled(origin, true); if (added) { - this._addOriginInput.value = ''; + input.value = ''; } } + /** */ async _updatePermissions() { const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); this._onPermissionsChanged({permissions}); } + /** + * @param {string} origin + * @param {boolean} enabled + * @returns {Promise<boolean>} + */ async _setOriginPermissionEnabled(origin, enabled) { let added = false; try { added = await this._settingsController.permissionsUtil.setPermissionsGranted({origins: [origin]}, enabled); } catch (e) { - this._errorContainer.hidden = false; - this._errorContainer.textContent = e.message; + const errorContainer = /** @type {HTMLElement} */ (this._errorContainer); + errorContainer.hidden = false; + errorContainer.textContent = e instanceof Error ? e.message : `${e}`; } if (!added) { return false; } await this._updatePermissions(); diff --git a/ext/js/pages/settings/permissions-toggle-controller.js b/ext/js/pages/settings/permissions-toggle-controller.js index 0e486c1e..85752a7e 100644 --- a/ext/js/pages/settings/permissions-toggle-controller.js +++ b/ext/js/pages/settings/permissions-toggle-controller.js @@ -19,11 +19,17 @@ import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js'; export class PermissionsToggleController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?NodeListOf<HTMLInputElement>} */ this._toggles = null; } + /** */ async prepare() { this._toggles = document.querySelectorAll('.permissions-toggle'); @@ -34,14 +40,18 @@ export class PermissionsToggleController { this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this)); const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { let accessor = null; - for (const toggle of this._toggles) { + for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) { const {permissionsSetting} = toggle.dataset; if (typeof permissionsSetting !== 'string') { continue; } @@ -61,8 +71,11 @@ export class PermissionsToggleController { this._updateValidity(); } + /** + * @param {Event} e + */ async _onPermissionsToggleChange(e) { - const toggle = e.currentTarget; + const toggle = /** @type {HTMLInputElement} */ (e.currentTarget); let value = toggle.checked; const valuePre = !value; const {permissionsSetting} = toggle.dataset; @@ -90,9 +103,13 @@ export class PermissionsToggleController { } } - _onPermissionsChanged({permissions: {permissions}}) { - const permissionsSet = new Set(permissions); - for (const toggle of this._toggles) { + /** + * @param {import('settings-controller').PermissionsChangedEvent} details + */ + _onPermissionsChanged({permissions}) { + const permissions2 = permissions.permissions; + const permissionsSet = new Set(typeof permissions2 !== 'undefined' ? permissions2 : []); + for (const toggle of /** @type {NodeListOf<HTMLInputElement>} */ (this._toggles)) { const {permissionsSetting} = toggle.dataset; const hasPermissions = this._hasAll(permissionsSet, this._getRequiredPermissions(toggle)); @@ -105,17 +122,27 @@ export class PermissionsToggleController { } } + /** + * @param {HTMLInputElement} toggle + * @param {boolean} valid + */ _setToggleValid(toggle, valid) { - const relative = toggle.closest('.settings-item'); + const relative = /** @type {?HTMLElement} */ (toggle.closest('.settings-item')); if (relative === null) { return; } relative.dataset.invalid = `${!valid}`; } + /** */ async _updateValidity() { const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); this._onPermissionsChanged({permissions}); } + /** + * @param {Set<string>} set + * @param {string[]} values + * @returns {boolean} + */ _hasAll(set, values) { for (const value of values) { if (!set.has(value)) { return false; } @@ -123,6 +150,10 @@ export class PermissionsToggleController { return true; } + /** + * @param {HTMLInputElement} toggle + * @returns {string[]} + */ _getRequiredPermissions(toggle) { const requiredPermissions = toggle.dataset.requiredPermissions; return (typeof requiredPermissions === 'string' && requiredPermissions.length > 0 ? requiredPermissions.split(' ') : []); diff --git a/ext/js/pages/settings/persistent-storage-controller.js b/ext/js/pages/settings/persistent-storage-controller.js index aa060c14..e85bfc6b 100644 --- a/ext/js/pages/settings/persistent-storage-controller.js +++ b/ext/js/pages/settings/persistent-storage-controller.js @@ -21,22 +21,27 @@ import {yomitan} from '../../yomitan.js'; export class PersistentStorageController { constructor() { - this._persistentStorageCheckbox = false; + /** @type {?HTMLInputElement} */ + this._persistentStorageCheckbox = null; } + /** */ async prepare() { - this._persistentStorageCheckbox = document.querySelector('#storage-persistent-checkbox'); + this._persistentStorageCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#storage-persistent-checkbox')); this._persistentStorageCheckbox.addEventListener('change', this._onPersistentStorageCheckboxChange.bind(this), false); if (!this._isPersistentStorageSupported()) { return; } - const info = document.querySelector('#storage-persistent-info'); + const info = /** @type {?HTMLElement} */ (document.querySelector('#storage-persistent-info')); if (info !== null) { info.hidden = false; } const isStoragePeristent = await this.isStoragePeristent(); this._updateCheckbox(isStoragePeristent); } + /** + * @returns {Promise<boolean>} + */ async isStoragePeristent() { try { return await navigator.storage.persisted(); @@ -48,8 +53,11 @@ export class PersistentStorageController { // Private + /** + * @param {Event} e + */ _onPersistentStorageCheckboxChange(e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); if (node.checked) { node.checked = false; this._attemptPersistStorage(); @@ -58,6 +66,7 @@ export class PersistentStorageController { } } + /** */ async _attemptPersistStorage() { let isStoragePeristent = false; try { @@ -68,18 +77,24 @@ export class PersistentStorageController { this._updateCheckbox(isStoragePeristent); - const node = document.querySelector('#storage-persistent-fail-warning'); + const node = /** @type {?HTMLElement} */ (document.querySelector('#storage-persistent-fail-warning')); if (node !== null) { node.hidden = isStoragePeristent; } yomitan.trigger('storageChanged'); } + /** + * @returns {boolean} + */ _isPersistentStorageSupported() { return isObject(navigator.storage) && typeof navigator.storage.persist === 'function'; } + /** + * @param {boolean} isStoragePeristent + */ _updateCheckbox(isStoragePeristent) { - this._persistentStorageCheckbox.checked = isStoragePeristent; - this._persistentStorageCheckbox.readOnly = isStoragePeristent; + /** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).checked = isStoragePeristent; + /** @type {HTMLInputElement} */ (this._persistentStorageCheckbox).readOnly = isStoragePeristent; } } diff --git a/ext/js/pages/settings/popup-preview-controller.js b/ext/js/pages/settings/popup-preview-controller.js index a0cb696e..7239ca17 100644 --- a/ext/js/pages/settings/popup-preview-controller.js +++ b/ext/js/pages/settings/popup-preview-controller.js @@ -17,22 +17,32 @@ */ export class PopupPreviewController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {string} */ this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + /** @type {?HTMLIFrameElement} */ this._frame = null; + /** @type {?HTMLTextAreaElement} */ this._customCss = null; + /** @type {?HTMLTextAreaElement} */ this._customOuterCss = null; + /** @type {?HTMLElement} */ this._previewFrameContainer = null; } + /** */ async prepare() { if (new URLSearchParams(location.search).get('popup-preview') === 'false') { return; } - this._frame = document.querySelector('#popup-preview-frame'); - this._customCss = document.querySelector('#custom-popup-css'); - this._customOuterCss = document.querySelector('#custom-popup-outer-css'); - this._previewFrameContainer = document.querySelector('.preview-frame-container'); + this._frame = /** @type {HTMLIFrameElement} */ (document.querySelector('#popup-preview-frame')); + this._customCss = /** @type {HTMLTextAreaElement} */ (document.querySelector('#custom-popup-css')); + this._customOuterCss = /** @type {HTMLTextAreaElement} */ (document.querySelector('#custom-popup-outer-css')); + this._previewFrameContainer = /** @type {HTMLElement} */ (document.querySelector('.preview-frame-container')); this._customCss.addEventListener('input', this._onCustomCssChange.bind(this), false); this._customCss.addEventListener('settingChanged', this._onCustomCssChange.bind(this), false); @@ -46,25 +56,35 @@ export class PopupPreviewController { // Private + /** */ _onFrameLoad() { this._onOptionsContextChange(); this._onCustomCssChange(); this._onCustomOuterCssChange(); } + /** */ _onCustomCssChange() { - this._invoke('PopupPreviewFrame.setCustomCss', {css: this._customCss.value}); + const css = /** @type {HTMLTextAreaElement} */ (this._customCss).value; + this._invoke('PopupPreviewFrame.setCustomCss', {css}); } + /** */ _onCustomOuterCssChange() { - this._invoke('PopupPreviewFrame.setCustomOuterCss', {css: this._customOuterCss.value}); + const css = /** @type {HTMLTextAreaElement} */ (this._customOuterCss).value; + this._invoke('PopupPreviewFrame.setCustomOuterCss', {css}); } + /** */ _onOptionsContextChange() { const optionsContext = this._settingsController.getOptionsContext(); this._invoke('PopupPreviewFrame.updateOptionsContext', {optionsContext}); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + */ _invoke(action, params) { if (this._frame === null || this._frame.contentWindow === null) { return; } this._frame.contentWindow.postMessage({action, params}, this._targetOrigin); diff --git a/ext/js/pages/settings/popup-preview-frame-main.js b/ext/js/pages/settings/popup-preview-frame-main.js index 59e409c5..bce485fe 100644 --- a/ext/js/pages/settings/popup-preview-frame-main.js +++ b/ext/js/pages/settings/popup-preview-frame-main.js @@ -27,6 +27,12 @@ import {PopupPreviewFrame} from './popup-preview-frame.js'; await yomitan.prepare(); const {tabId, frameId} = await yomitan.api.frameInformationGet(); + if (typeof tabId === 'undefined') { + throw new Error('Failed to get tabId'); + } + if (typeof frameId === 'undefined') { + throw new Error('Failed to get frameId'); + } const hotkeyHandler = new HotkeyHandler(); hotkeyHandler.prepare(); diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js index 7a1a0b3a..60d264fa 100644 --- a/ext/js/pages/settings/popup-preview-frame.js +++ b/ext/js/pages/settings/popup-preview-frame.js @@ -22,32 +22,53 @@ import {TextSourceRange} from '../../dom/text-source-range.js'; import {yomitan} from '../../yomitan.js'; export class PopupPreviewFrame { + /** + * @param {number} tabId + * @param {number} frameId + * @param {import('../../app/popup-factory.js').PopupFactory} popupFactory + * @param {import('../../input/hotkey-handler.js').HotkeyHandler} hotkeyHandler + */ constructor(tabId, frameId, popupFactory, hotkeyHandler) { + /** @type {number} */ this._tabId = tabId; + /** @type {number} */ this._frameId = frameId; + /** @type {import('../../app/popup-factory.js').PopupFactory} */ this._popupFactory = popupFactory; + /** @type {import('../../input/hotkey-handler.js').HotkeyHandler} */ this._hotkeyHandler = hotkeyHandler; + /** @type {?Frontend} */ this._frontend = null; + /** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */ this._apiOptionsGetOld = null; + /** @type {boolean} */ this._popupShown = false; + /** @type {?import('core').Timeout} */ this._themeChangeTimeout = null; + /** @type {?import('text-source').TextSource} */ this._textSource = null; + /** @type {?import('settings').OptionsContext} */ this._optionsContext = null; + /** @type {?HTMLElement} */ this._exampleText = null; + /** @type {?HTMLInputElement} */ this._exampleTextInput = null; + /** @type {string} */ this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); - this._windowMessageHandlers = new Map([ + /** @type {Map<string, (params: import('core').SerializableObjectAny) => void>} */ + this._windowMessageHandlers = new Map(/** @type {[key: string, handler: (params: import('core').SerializableObjectAny) => void][]} */ ([ ['PopupPreviewFrame.setText', this._onSetText.bind(this)], ['PopupPreviewFrame.setCustomCss', this._setCustomCss.bind(this)], ['PopupPreviewFrame.setCustomOuterCss', this._setCustomOuterCss.bind(this)], ['PopupPreviewFrame.updateOptionsContext', this._updateOptionsContext.bind(this)] - ]); + ])); } + /** */ async prepare() { - this._exampleText = document.querySelector('#example-text'); - this._exampleTextInput = document.querySelector('#example-text-input'); + this._exampleText = /** @type {HTMLElement} */ (document.querySelector('#example-text')); + this._exampleTextInput = /** @type {HTMLInputElement} */ (document.querySelector('#example-text-input')); if (this._exampleTextInput !== null && typeof wanakana !== 'undefined') { wanakana.bind(this._exampleTextInput); @@ -56,12 +77,14 @@ export class PopupPreviewFrame { window.addEventListener('message', this._onMessage.bind(this), false); // Setup events - document.querySelector('#theme-dark-checkbox').addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false); + const darkThemeCheckbox = /** @type {HTMLInputElement} */ (document.querySelector('#theme-dark-checkbox')); + darkThemeCheckbox.addEventListener('change', this._onThemeDarkCheckboxChanged.bind(this), false); this._exampleText.addEventListener('click', this._onExampleTextClick.bind(this), false); this._exampleTextInput.addEventListener('blur', this._onExampleTextInputBlur.bind(this), false); this._exampleTextInput.addEventListener('input', this._onExampleTextInputInput.bind(this), false); // Overwrite API functions + /** @type {?(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */ this._apiOptionsGetOld = yomitan.api.optionsGet.bind(yomitan.api); yomitan.api.optionsGet = this._apiOptionsGet.bind(this); @@ -84,7 +107,10 @@ export class PopupPreviewFrame { await this._frontend.prepare(); this._frontend.setDisabledOverride(true); this._frontend.canClearSelection = false; - this._frontend.popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this)); + const {popup} = this._frontend; + if (popup !== null) { + popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this)); + } // Update search this._updateSearch(); @@ -92,8 +118,12 @@ export class PopupPreviewFrame { // Private - async _apiOptionsGet(...args) { - const options = await this._apiOptionsGetOld(...args); + /** + * @param {import('settings').OptionsContext} optionsContext + * @returns {Promise<import('settings').ProfileOptions>} + */ + async _apiOptionsGet(optionsContext) { + const options = await /** @type {(optionsContext: import('settings').OptionsContext) => Promise<import('settings').ProfileOptions>} */ (this._apiOptionsGetOld)(optionsContext); options.general.enable = true; options.general.debugInfo = false; options.general.popupWidth = 400; @@ -108,16 +138,24 @@ export class PopupPreviewFrame { return options; } + /** + * @param {import('popup').CustomOuterCssChangedEvent} details + */ _onCustomOuterCssChanged({node, inShadow}) { if (node === null || inShadow) { return; } const node2 = document.querySelector('#popup-outer-css'); if (node2 === null) { return; } + const {parentNode} = node2; + if (parentNode === null) { return; } // This simulates the stylesheet priorities when injecting using the web extension API. - node2.parentNode.insertBefore(node, node2); + parentNode.insertBefore(node, node2); } + /** + * @param {MessageEvent<{action: string, params: import('core').SerializableObject}>} e + */ _onMessage(e) { if (e.origin !== this._targetOrigin) { return; } @@ -128,19 +166,24 @@ export class PopupPreviewFrame { handler(params); } + /** + * @param {Event} e + */ _onThemeDarkCheckboxChanged(e) { - document.documentElement.classList.toggle('dark', e.target.checked); + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + document.documentElement.classList.toggle('dark', element.checked); if (this._themeChangeTimeout !== null) { clearTimeout(this._themeChangeTimeout); } this._themeChangeTimeout = setTimeout(() => { this._themeChangeTimeout = null; - const popup = this._frontend.popup; + const popup = /** @type {Frontend} */ (this._frontend).popup; if (popup === null) { return; } popup.updateTheme(); }, 300); } + /** */ _onExampleTextClick() { if (this._exampleTextInput === null) { return; } const visible = this._exampleTextInput.hidden; @@ -150,19 +193,31 @@ export class PopupPreviewFrame { this._exampleTextInput.select(); } + /** */ _onExampleTextInputBlur() { if (this._exampleTextInput === null) { return; } this._exampleTextInput.hidden = true; } + /** + * @param {Event} e + */ _onExampleTextInputInput(e) { - this._setText(e.currentTarget.value); + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + this._setText(element.value, false); } + /** + * @param {{text: string}} details + */ _onSetText({text}) { this._setText(text, true); } + /** + * @param {string} text + * @param {boolean} setInput + */ _setText(text, setInput) { if (setInput && this._exampleTextInput !== null) { this._exampleTextInput.value = text; @@ -175,6 +230,9 @@ export class PopupPreviewFrame { this._updateSearch(); } + /** + * @param {boolean} visible + */ _setInfoVisible(visible) { const node = document.querySelector('.placeholder-info'); if (node === null) { return; } @@ -182,6 +240,9 @@ export class PopupPreviewFrame { node.classList.toggle('placeholder-info-visible', visible); } + /** + * @param {{css: string}} details + */ _setCustomCss({css}) { if (this._frontend === null) { return; } const popup = this._frontend.popup; @@ -189,6 +250,9 @@ export class PopupPreviewFrame { popup.setCustomCss(css); } + /** + * @param {{css: string}} details + */ _setCustomOuterCss({css}) { if (this._frontend === null) { return; } const popup = this._frontend.popup; @@ -196,7 +260,11 @@ export class PopupPreviewFrame { popup.setCustomOuterCss(css, false); } - async _updateOptionsContext({optionsContext}) { + /** + * @param {{optionsContext: import('settings').OptionsContext}} details + */ + async _updateOptionsContext(details) { + const {optionsContext} = details; this._optionsContext = optionsContext; if (this._frontend === null) { return; } this._frontend.setOptionsContextOverride(optionsContext); @@ -204,6 +272,7 @@ export class PopupPreviewFrame { await this._updateSearch(); } + /** */ async _updateSearch() { if (this._exampleText === null) { return; } @@ -213,16 +282,17 @@ export class PopupPreviewFrame { const range = document.createRange(); range.selectNodeContents(textNode); const source = TextSourceRange.create(range); + const frontend = /** @type {Frontend} */ (this._frontend); try { - await this._frontend.setTextSource(source); + await frontend.setTextSource(source); } finally { source.cleanup(); } this._textSource = source; - await this._frontend.showContentCompleted(); + await frontend.showContentCompleted(); - const popup = this._frontend.popup; + const popup = frontend.popup; if (popup !== null && popup.isVisibleSync()) { this._popupShown = true; } diff --git a/ext/js/pages/settings/popup-window-controller.js b/ext/js/pages/settings/popup-window-controller.js index 9b6708d5..e1a5456b 100644 --- a/ext/js/pages/settings/popup-window-controller.js +++ b/ext/js/pages/settings/popup-window-controller.js @@ -19,18 +19,23 @@ import {yomitan} from '../../yomitan.js'; export class PopupWindowController { + /** */ prepare() { - const testLink = document.querySelector('#test-window-open-link'); + const testLink = /** @type {HTMLElement} */ (document.querySelector('#test-window-open-link')); testLink.addEventListener('click', this._onTestWindowOpenLinkClick.bind(this), false); } // Private + /** + * @param {MouseEvent} e + */ _onTestWindowOpenLinkClick(e) { e.preventDefault(); this._testWindowOpen(); } + /** */ async _testWindowOpen() { await yomitan.api.getOrCreateSearchPopup({focus: true}); } diff --git a/ext/js/pages/settings/profile-conditions-ui.js b/ext/js/pages/settings/profile-conditions-ui.js index bd790b1b..96aef83f 100644 --- a/ext/js/pages/settings/profile-conditions-ui.js +++ b/ext/js/pages/settings/profile-conditions-ui.js @@ -17,23 +17,39 @@ */ import {EventDispatcher, EventListenerCollection} from '../../core.js'; +import {DocumentUtil} from '../../dom/document-util.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; +/** + * @augments EventDispatcher<import('profile-conditions-ui').EventType> + */ export class ProfileConditionsUI extends EventDispatcher { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { super(); + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?import('environment').OperatingSystem} */ this._os = null; + /** @type {?HTMLElement} */ this._conditionGroupsContainer = null; + /** @type {?HTMLElement} */ this._addConditionGroupButton = null; + /** @type {ProfileConditionGroupUI[]} */ this._children = []; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {import('profile-conditions-ui').DescriptorType} */ this._defaultType = 'popupLevel'; + /** @type {number} */ this._profileIndex = 0; const validateInteger = this._validateInteger.bind(this); const normalizeInteger = this._normalizeInteger.bind(this); const validateFlags = this._validateFlags.bind(this); const normalizeFlags = this._normalizeFlags.bind(this); + /** @type {Map<import('profile-conditions-ui').DescriptorType, import('profile-conditions-ui').Descriptor>} */ this._descriptors = new Map([ [ 'popupLevel', @@ -88,19 +104,23 @@ export class ProfileConditionsUI extends EventDispatcher { } ] ]); + /** @type {Set<string>} */ this._validFlags = new Set([ 'clipboard' ]); } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._settingsController; } + /** @type {number} */ get profileIndex() { return this._profileIndex; } + /** @type {?import('environment').OperatingSystem} */ get os() { return this._os; } @@ -109,6 +129,9 @@ export class ProfileConditionsUI extends EventDispatcher { this._os = value; } + /** + * @param {number} profileIndex + */ async prepare(profileIndex) { const options = await this._settingsController.getOptionsFull(); const {profiles} = options; @@ -116,8 +139,8 @@ export class ProfileConditionsUI extends EventDispatcher { const {conditionGroups} = profiles[profileIndex]; this._profileIndex = profileIndex; - this._conditionGroupsContainer = document.querySelector('#profile-condition-groups'); - this._addConditionGroupButton = document.querySelector('#profile-add-condition-group'); + this._conditionGroupsContainer = /** @type {HTMLElement} */ (document.querySelector('#profile-condition-groups')); + this._addConditionGroupButton = /** @type {HTMLElement} */ (document.querySelector('#profile-add-condition-group')); for (let i = 0, ii = conditionGroups.length; i < ii; ++i) { this._addConditionGroup(conditionGroups[i], i); @@ -126,6 +149,7 @@ export class ProfileConditionsUI extends EventDispatcher { this._eventListeners.addEventListener(this._addConditionGroupButton, 'click', this._onAddConditionGroupButtonClick.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); @@ -138,10 +162,17 @@ export class ProfileConditionsUI extends EventDispatcher { this._addConditionGroupButton = null; } - instantiateTemplate(names) { - return this._settingsController.instantiateTemplate(names); + /** + * @param {string} name + * @returns {HTMLElement} + */ + instantiateTemplate(name) { + return /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate(name)); } + /** + * @returns {import('profile-conditions-ui').DescriptorInfo[]} + */ getDescriptorTypes() { const results = []; for (const [name, {displayName}] of this._descriptors.entries()) { @@ -150,6 +181,10 @@ export class ProfileConditionsUI extends EventDispatcher { return results; } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @returns {import('profile-conditions-ui').OperatorInfo[]} + */ getDescriptorOperators(type) { const info = this._descriptors.get(type); const results = []; @@ -161,15 +196,27 @@ export class ProfileConditionsUI extends EventDispatcher { return results; } + /** + * @returns {import('profile-conditions-ui').DescriptorType} + */ getDefaultType() { return this._defaultType; } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @returns {string} + */ getDefaultOperator(type) { const info = this._descriptors.get(type); return (typeof info !== 'undefined' ? info.defaultOperator : ''); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} operator + * @returns {import('profile-conditions-ui').Operator} + */ getOperatorDetails(type, operator) { const info = this._getOperatorDetails(type, operator); @@ -192,6 +239,9 @@ export class ProfileConditionsUI extends EventDispatcher { }; } + /** + * @returns {import('settings').ProfileCondition} + */ getDefaultCondition() { const type = this.getDefaultType(); const operator = this.getDefaultOperator(type); @@ -199,6 +249,10 @@ export class ProfileConditionsUI extends EventDispatcher { return {type, operator, value}; } + /** + * @param {ProfileConditionGroupUI} child + * @returns {boolean} + */ removeConditionGroup(child) { const index = child.index; if (index < 0 || index >= this._children.length) { return false; } @@ -226,22 +280,53 @@ export class ProfileConditionsUI extends EventDispatcher { return true; } + /** + * @param {string} value + * @returns {string[]} + */ splitValue(value) { return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); } + /** + * @param {string} property + * @returns {string} + */ getPath(property) { property = (typeof property === 'string' ? `.${property}` : ''); return `profiles[${this.profileIndex}]${property}`; } + /** + * @param {HTMLInputElement} inputNode + * @param {?HTMLButtonElement} mouseButton + * @returns {KeyboardMouseInputField} + */ createKeyboardMouseInputField(inputNode, mouseButton) { return new KeyboardMouseInputField(inputNode, mouseButton, this._os); } + /** + * @param {string} value + * @returns {?import('settings').ProfileConditionType} + */ + static normalizeProfileConditionType(value) { + switch (value) { + case 'popupLevel': + case 'url': + case 'modifierKeys': + case 'flags': + return value; + default: + return null; + } + } + // Private + /** */ _onAddConditionGroupButtonClick() { + /** @type {import('settings').ProfileConditionGroup} */ const conditionGroup = { conditions: [this.getDefaultCondition()] }; @@ -260,28 +345,50 @@ export class ProfileConditionsUI extends EventDispatcher { this._triggerConditionGroupCountChanged(this._children.length); } + /** + * @param {import('settings').ProfileConditionGroup} conditionGroup + * @param {number} index + * @returns {ProfileConditionGroupUI} + */ _addConditionGroup(conditionGroup, index) { const child = new ProfileConditionGroupUI(this, index); child.prepare(conditionGroup); this._children.push(child); - this._conditionGroupsContainer.appendChild(child.node); + /** @type {HTMLElement} */ (this._conditionGroupsContainer).appendChild(child.node); return child; } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} operator + * @returns {import('profile-conditions-ui').OperatorInternal|undefined} + */ _getOperatorDetails(type, operator) { const info = this._descriptors.get(type); return (typeof info !== 'undefined' ? info.operators.get(operator) : void 0); } + /** + * @param {string} value + * @returns {boolean} + */ _validateInteger(value) { const number = Number.parseFloat(value); return Number.isFinite(number) && Math.floor(number) === number; } + /** + * @param {string} value + * @returns {boolean} + */ _validateDomains(value) { return this.splitValue(value).length > 0; } + /** + * @param {string} value + * @returns {boolean} + */ _validateRegExp(value) { try { new RegExp(value, 'i'); @@ -291,15 +398,27 @@ export class ProfileConditionsUI extends EventDispatcher { } } + /** + * @param {string} value + * @returns {string} + */ _normalizeInteger(value) { const number = Number.parseFloat(value); return `${number}`; } + /** + * @param {string} value + * @returns {string} + */ _normalizeDomains(value) { return this.splitValue(value).join(', '); } + /** + * @param {string} value + * @returns {boolean} + */ _validateFlags(value) { const flags = this.splitValue(value); for (const flag of flags) { @@ -310,34 +429,57 @@ export class ProfileConditionsUI extends EventDispatcher { return flags.length > 0; } + /** + * @param {string} value + * @returns {string} + */ _normalizeFlags(value) { return [...new Set(this.splitValue(value))].join(', '); } + /** + * @param {number} count + */ _triggerConditionGroupCountChanged(count) { - this.trigger('conditionGroupCountChanged', {count, profileIndex: this._profileIndex}); + /** @type {import('profile-conditions-ui').ConditionGroupCountChangedEvent} */ + const event = {count, profileIndex: this._profileIndex}; + this.trigger('conditionGroupCountChanged', event); } } class ProfileConditionGroupUI { + /** + * @param {ProfileConditionsUI} parent + * @param {number} index + */ constructor(parent, index) { + /** @type {ProfileConditionsUI} */ this._parent = parent; + /** @type {number} */ this._index = index; - this._node = null; - this._conditionContainer = null; - this._addConditionButton = null; + /** @type {HTMLElement} */ + this._node = /** @type {HTMLElement} */ (this._parent.instantiateTemplate('profile-condition-group')); + /** @type {HTMLElement} */ + this._conditionContainer = /** @type {HTMLElement} */ (this._node.querySelector('.profile-condition-list')); + /** @type {HTMLElement} */ + this._addConditionButton = /** @type {HTMLElement} */ (this._node.querySelector('.profile-condition-add-button')); + /** @type {ProfileConditionUI[]} */ this._children = []; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._parent.settingsController; } + /** @type {ProfileConditionsUI} */ get parent() { return this._parent; } + /** @type {number} */ get index() { return this._index; } @@ -346,19 +488,20 @@ class ProfileConditionGroupUI { this._index = value; } + /** @type {HTMLElement} */ get node() { return this._node; } + /** @type {number} */ get childCount() { return this._children.length; } + /** + * @param {import('settings').ProfileConditionGroup} conditionGroup + */ prepare(conditionGroup) { - this._node = this._parent.instantiateTemplate('profile-condition-group'); - this._conditionContainer = this._node.querySelector('.profile-condition-list'); - this._addConditionButton = this._node.querySelector('.profile-condition-add-button'); - const conditions = conditionGroup.conditions; for (let i = 0, ii = conditions.length; i < ii; ++i) { this._addCondition(conditions[i], i); @@ -367,6 +510,7 @@ class ProfileConditionGroupUI { this._eventListeners.addEventListener(this._addConditionButton, 'click', this._onAddConditionButtonClick.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); @@ -378,15 +522,15 @@ class ProfileConditionGroupUI { if (this._node === null) { return; } const node = this._node; - this._node = null; - this._conditionContainer = null; - this._addConditionButton = null; - if (node.parentNode !== null) { node.parentNode.removeChild(node); } } + /** + * @param {ProfileConditionUI} child + * @returns {boolean} + */ removeCondition(child) { const index = child.index; if (index < 0 || index >= this._children.length) { return false; } @@ -416,17 +560,23 @@ class ProfileConditionGroupUI { return true; } + /** + * @param {string} property + * @returns {string} + */ getPath(property) { property = (typeof property === 'string' ? `.${property}` : ''); return this._parent.getPath(`conditionGroups[${this._index}]${property}`); } + /** */ removeSelf() { this._parent.removeConditionGroup(this); } // Private + /** */ _onAddConditionButtonClick() { const condition = this._parent.getDefaultCondition(); const index = this._children.length; @@ -442,41 +592,73 @@ class ProfileConditionGroupUI { }]); } + /** + * @param {import('settings').ProfileCondition} condition + * @param {number} index + * @returns {ProfileConditionUI} + */ _addCondition(condition, index) { const child = new ProfileConditionUI(this, index); child.prepare(condition); this._children.push(child); - this._conditionContainer.appendChild(child.node); + if (this._conditionContainer !== null) { + this._conditionContainer.appendChild(child.node); + } return child; } } class ProfileConditionUI { + /** + * @param {ProfileConditionGroupUI} parent + * @param {number} index + */ constructor(parent, index) { + /** @type {ProfileConditionGroupUI} */ this._parent = parent; + /** @type {number} */ this._index = index; - this._node = null; - this._typeInput = null; - this._operatorInput = null; - this._valueInputContainer = null; - this._removeButton = null; - this._mouseButton = null; - this._mouseButtonContainer = null; - this._menuButton = null; + /** @type {HTMLElement} */ + this._node = this._parent.parent.instantiateTemplate('profile-condition'); + /** @type {HTMLSelectElement} */ + this._typeInput = /** @type {HTMLSelectElement} */ (this._node.querySelector('.profile-condition-type')); + /** @type {HTMLSelectElement} */ + this._operatorInput = /** @type {HTMLSelectElement} */ (this._node.querySelector('.profile-condition-operator')); + /** @type {HTMLButtonElement} */ + this._removeButton = /** @type {HTMLButtonElement} */ (this._node.querySelector('.profile-condition-remove')); + /** @type {HTMLButtonElement} */ + this._mouseButton = /** @type {HTMLButtonElement} */ (this._node.querySelector('.mouse-button')); + /** @type {HTMLElement} */ + this._mouseButtonContainer = /** @type {HTMLElement} */ (this._node.querySelector('.mouse-button-container')); + /** @type {HTMLButtonElement} */ + this._menuButton = /** @type {HTMLButtonElement} */ (this._node.querySelector('.profile-condition-menu-button')); + /** @type {HTMLElement} */ + this._typeOptionContainer = /** @type {HTMLElement} */ (this._typeInput.querySelector('optgroup')); + /** @type {HTMLElement} */ + this._operatorOptionContainer = /** @type {HTMLElement} */ (this._operatorInput.querySelector('optgroup')); + /** @type {HTMLInputElement} */ + this._valueInput = /** @type {HTMLInputElement} */ (this._node.querySelector('.profile-condition-input')); + /** @type {string} */ this._value = ''; + /** @type {?KeyboardMouseInputField} */ this._kbmInputField = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {EventListenerCollection} */ this._inputEventListeners = new EventListenerCollection(); } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._parent.parent.settingsController; } + /** @type {ProfileConditionGroupUI} */ get parent() { return this._parent; } + /** @type {number} */ get index() { return this._index; } @@ -485,24 +667,17 @@ class ProfileConditionUI { this._index = value; } + /** @type {HTMLElement} */ get node() { return this._node; } + /** + * @param {import('settings').ProfileCondition} condition + */ prepare(condition) { const {type, operator, value} = condition; - this._node = this._parent.parent.instantiateTemplate('profile-condition'); - this._typeInput = this._node.querySelector('.profile-condition-type'); - this._typeOptionContainer = this._typeInput.querySelector('optgroup'); - this._operatorInput = this._node.querySelector('.profile-condition-operator'); - this._operatorOptionContainer = this._operatorInput.querySelector('optgroup'); - this._valueInput = this._node.querySelector('.profile-condition-input'); - this._removeButton = this._node.querySelector('.profile-condition-remove'); - this._mouseButton = this._node.querySelector('.mouse-button'); - this._mouseButtonContainer = this._node.querySelector('.mouse-button-container'); - this._menuButton = this._node.querySelector('.profile-condition-menu-button'); - const operatorDetails = this._getOperatorDetails(type, operator); this._updateTypes(type); this._updateOperators(type, operator); @@ -517,6 +692,7 @@ class ProfileConditionUI { } } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); this._value = ''; @@ -524,17 +700,15 @@ class ProfileConditionUI { if (this._node === null) { return; } const node = this._node; - this._node = null; - this._typeInput = null; - this._operatorInput = null; - this._valueInputContainer = null; - this._removeButton = null; - if (node.parentNode !== null) { node.parentNode.removeChild(node); } } + /** + * @param {string} property + * @returns {string} + */ getPath(property) { property = (typeof property === 'string' ? `.${property}` : ''); return this._parent.getPath(`conditions[${this._index}]${property}`); @@ -542,19 +716,33 @@ class ProfileConditionUI { // Private + /** + * @param {Event} e + */ _onTypeChange(e) { - const type = e.currentTarget.value; + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const type = ProfileConditionsUI.normalizeProfileConditionType(element.value); + if (type === null) { return; } this._setType(type); } + /** + * @param {Event} e + */ _onOperatorChange(e) { - const type = this._typeInput.value; - const operator = e.currentTarget.value; + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const type = ProfileConditionsUI.normalizeProfileConditionType(this._typeInput.value); + if (type === null) { return; } + const operator = element.value; this._setOperator(type, operator); } + /** + * @param {import('profile-conditions-ui').InputData} details + * @param {Event} e + */ _onValueInputChange({validate, normalize}, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); const value = node.value; const okay = this._validateValue(value, validate); this._value = value; @@ -565,8 +753,12 @@ class ProfileConditionUI { } } - _onModifierInputChange({validate, normalize}, {modifiers}) { - modifiers = this._joinModifiers(modifiers); + /** + * @param {import('profile-conditions-ui').InputData} details + * @param {import('keyboard-mouse-input-field').ChangeEvent} event + */ + _onModifierInputChange({validate, normalize}, event) { + const modifiers = this._joinModifiers(event.modifiers); const okay = this._validateValue(modifiers, validate); this._value = modifiers; if (okay) { @@ -575,18 +767,25 @@ class ProfileConditionUI { } } + /** */ _onRemoveButtonClick() { this._removeSelf(); } + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const bodyNode = e.detail.menu.bodyNode; - const deleteGroup = bodyNode.querySelector('.popup-menu-item[data-menu-action="deleteGroup"]'); + const deleteGroup = /** @type {HTMLElement} */ (bodyNode.querySelector('.popup-menu-item[data-menu-action="deleteGroup"]')); if (deleteGroup !== null) { deleteGroup.hidden = (this._parent.childCount <= 1); } } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'delete': @@ -601,28 +800,53 @@ class ProfileConditionUI { } } + /** + * @returns {import('profile-conditions-ui').DescriptorInfo[]} + */ _getDescriptorTypes() { return this._parent.parent.getDescriptorTypes(); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @returns {import('profile-conditions-ui').OperatorInfo[]} + */ _getDescriptorOperators(type) { return this._parent.parent.getDescriptorOperators(type); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} operator + * @returns {import('profile-conditions-ui').Operator} + */ _getOperatorDetails(type, operator) { return this._parent.parent.getOperatorDetails(type, operator); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + */ _updateTypes(type) { const types = this._getDescriptorTypes(); this._updateSelect(this._typeInput, this._typeOptionContainer, types, type); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} operator + */ _updateOperators(type, operator) { const operators = this._getDescriptorOperators(type); this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator); } + /** + * @param {HTMLSelectElement} select + * @param {HTMLElement} optionContainer + * @param {import('profile-conditions-ui').DescriptorInfo[]|import('profile-conditions-ui').OperatorInfo[]} values + * @param {string} value + */ _updateSelect(select, optionContainer, values, value) { optionContainer.textContent = ''; for (const {name, displayName} of values) { @@ -634,6 +858,11 @@ class ProfileConditionUI { select.value = value; } + /** + * @param {string} value + * @param {import('profile-conditions-ui').Operator} operator + * @returns {boolean} + */ _updateValueInput(value, {type, validate, normalize}) { this._inputEventListeners.removeAllEventListeners(); if (this._kbmInputField !== null) { @@ -642,10 +871,15 @@ class ProfileConditionUI { } let inputType = 'text'; + /** @type {?string} */ let inputValue = value; let inputStep = null; let showMouseButton = false; - const events = []; + /** @type {import('event-listener-collection').AddEventListenerArgs[]} */ + const events1 = []; + /** @type {import('event-listener-collection').OnArgs[]} */ + const events2 = []; + /** @type {import('profile-conditions-ui').InputData} */ const inputData = {validate, normalize}; const node = this._valueInput; @@ -653,7 +887,7 @@ class ProfileConditionUI { case 'integer': inputType = 'number'; inputStep = '1'; - events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]); + events1.push([node, 'change', this._onValueInputChange.bind(this, inputData), false]); break; case 'modifierKeys': case 'modifierInputs': @@ -661,10 +895,10 @@ class ProfileConditionUI { showMouseButton = (type === 'modifierInputs'); this._kbmInputField = this._parent.parent.createKeyboardMouseInputField(node, this._mouseButton); this._kbmInputField.prepare(null, this._splitModifiers(value), showMouseButton, false); - events.push(['on', this._kbmInputField, 'change', this._onModifierInputChange.bind(this, inputData), false]); + events2.push([this._kbmInputField, 'change', this._onModifierInputChange.bind(this, inputData)]); break; default: // 'string' - events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]); + events1.push([node, 'change', this._onValueInputChange.bind(this, inputData), false]); break; } @@ -680,35 +914,67 @@ class ProfileConditionUI { node.removeAttribute('step'); } this._mouseButtonContainer.hidden = !showMouseButton; - for (const args of events) { - this._inputEventListeners.addGeneric(...args); + for (const args of events1) { + this._inputEventListeners.addEventListener(...args); + } + for (const args of events2) { + this._inputEventListeners.on(...args); } - this._validateValue(value, validate); + return this._validateValue(value, validate); } + /** + * @param {string} value + * @param {?import('profile-conditions-ui').ValidateFunction} validate + * @returns {boolean} + */ _validateValue(value, validate) { const okay = (validate === null || validate(value)); this._valueInput.dataset.invalid = `${!okay}`; return okay; } + /** + * @param {string} value + * @param {?import('profile-conditions-ui').NormalizeFunction} normalize + * @returns {value} + */ _normalizeValue(value, normalize) { return (normalize !== null ? normalize(value) : value); } + /** */ _removeSelf() { this._parent.removeCondition(this); } + /** + * @param {string} modifiersString + * @returns {import('input').Modifier[]} + */ _splitModifiers(modifiersString) { - return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + /** @type {import('input').Modifier[]} */ + const results = []; + for (const item of modifiersString.split(/[,;\s]+/)) { + const modifier = DocumentUtil.normalizeModifier(item.trim().toLowerCase()); + if (modifier !== null) { results.push(modifier); } + } + return results; } + /** + * @param {import('input').Modifier[]} modifiersArray + * @returns {string} + */ _joinModifiers(modifiersArray) { return modifiersArray.join(', '); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} [operator] + */ async _setType(type, operator) { const operators = this._getDescriptorOperators(type); if (typeof operator === 'undefined') { @@ -725,8 +991,13 @@ class ProfileConditionUI { ]); } + /** + * @param {import('profile-conditions-ui').DescriptorType} type + * @param {string} operator + */ async _setOperator(type, operator) { const operatorDetails = this._getOperatorDetails(type, operator); + /** @type {import('settings-modifications').Modification[]} */ const settingsModifications = [{action: 'set', path: this.getPath('operator'), value: operator}]; if (operatorDetails.resetDefaultOnChange) { const {defaultValue} = operatorDetails; @@ -738,8 +1009,10 @@ class ProfileConditionUI { await this.settingsController.modifyGlobalSettings(settingsModifications); } + /** */ async _resetValue() { - const type = this._typeInput.value; + const type = ProfileConditionsUI.normalizeProfileConditionType(this._typeInput.value); + if (type === null) { return; } const operator = this._operatorInput.value; await this._setType(type, operator); } diff --git a/ext/js/pages/settings/profile-controller.js b/ext/js/pages/settings/profile-controller.js index a5bf41b3..c82223b8 100644 --- a/ext/js/pages/settings/profile-controller.js +++ b/ext/js/pages/settings/profile-controller.js @@ -21,50 +21,77 @@ import {yomitan} from '../../yomitan.js'; import {ProfileConditionsUI} from './profile-conditions-ui.js'; export class ProfileController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {ProfileConditionsUI} */ this._profileConditionsUI = new ProfileConditionsUI(settingsController); + /** @type {?number} */ this._profileConditionsIndex = null; + /** @type {?HTMLSelectElement} */ this._profileActiveSelect = null; + /** @type {?HTMLSelectElement} */ this._profileTargetSelect = null; + /** @type {?HTMLSelectElement} */ this._profileCopySourceSelect = null; + /** @type {?HTMLElement} */ this._removeProfileNameElement = null; + /** @type {?HTMLButtonElement} */ this._profileAddButton = null; + /** @type {?HTMLButtonElement} */ this._profileRemoveConfirmButton = null; + /** @type {?HTMLButtonElement} */ this._profileCopyConfirmButton = null; + /** @type {?HTMLElement} */ this._profileEntryListContainer = null; + /** @type {?HTMLElement} */ this._profileConditionsProfileName = null; + /** @type {?import('./modal.js').Modal} */ this._profileRemoveModal = null; + /** @type {?import('./modal.js').Modal} */ this._profileCopyModal = null; + /** @type {?import('./modal.js').Modal} */ this._profileConditionsModal = null; + /** @type {boolean} */ this._profileEntriesSupported = false; + /** @type {ProfileEntry[]} */ this._profileEntryList = []; + /** @type {import('settings').Profile[]} */ this._profiles = []; + /** @type {number} */ this._profileCurrent = 0; } + /** @type {number} */ get profileCount() { return this._profiles.length; } + /** @type {number} */ get profileCurrentIndex() { return this._profileCurrent; } + /** */ async prepare() { const {platform: {os}} = await yomitan.api.getEnvironmentInfo(); this._profileConditionsUI.os = os; - this._profileActiveSelect = document.querySelector('#profile-active-select'); - this._profileTargetSelect = document.querySelector('#profile-target-select'); - this._profileCopySourceSelect = document.querySelector('#profile-copy-source-select'); - this._removeProfileNameElement = document.querySelector('#profile-remove-name'); - this._profileAddButton = document.querySelector('#profile-add-button'); - this._profileRemoveConfirmButton = document.querySelector('#profile-remove-confirm-button'); - this._profileCopyConfirmButton = document.querySelector('#profile-copy-confirm-button'); - this._profileEntryListContainer = document.querySelector('#profile-entry-list'); - this._profileConditionsProfileName = document.querySelector('#profile-conditions-profile-name'); + this._profileActiveSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#profile-active-select')); + this._profileTargetSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#profile-target-select')); + this._profileCopySourceSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#profile-copy-source-select')); + this._removeProfileNameElement = /** @type {HTMLElement} */ (document.querySelector('#profile-remove-name')); + this._profileAddButton = /** @type {HTMLButtonElement} */ (document.querySelector('#profile-add-button')); + this._profileRemoveConfirmButton = /** @type {HTMLButtonElement} */ (document.querySelector('#profile-remove-confirm-button')); + this._profileCopyConfirmButton = /** @type {HTMLButtonElement} */ (document.querySelector('#profile-copy-confirm-button')); + this._profileEntryListContainer = /** @type {HTMLElement} */ (document.querySelector('#profile-entry-list')); + this._profileConditionsProfileName = /** @type {HTMLElement} */ (document.querySelector('#profile-conditions-profile-name')); this._profileRemoveModal = this._modalController.getModal('profile-remove'); this._profileCopyModal = this._modalController.getModal('profile-copy'); this._profileConditionsModal = this._modalController.getModal('profile-conditions'); @@ -82,6 +109,10 @@ export class ProfileController { this._onOptionsChanged(); } + /** + * @param {number} profileIndex + * @param {number} offset + */ async moveProfile(profileIndex, offset) { if (this._getProfile(profileIndex) === null) { return; } @@ -91,6 +122,10 @@ export class ProfileController { await this.swapProfiles(profileIndex, profileIndexNew); } + /** + * @param {number} profileIndex + * @param {string} value + */ async setProfileName(profileIndex, value) { const profile = this._getProfile(profileIndex); if (profile === null) { return; } @@ -104,11 +139,14 @@ export class ProfileController { await this._settingsController.setGlobalSetting(`profiles[${profileIndex}].name`, value); } + /** + * @param {number} profileIndex + */ async setDefaultProfile(profileIndex) { const profile = this._getProfile(profileIndex); if (profile === null) { return; } - this._profileActiveSelect.value = `${profileIndex}`; + /** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileIndex}`; this._profileCurrent = profileIndex; const profileEntry = this._getProfileEntry(profileIndex); @@ -117,6 +155,10 @@ export class ProfileController { await this._settingsController.setGlobalSetting('profileCurrent', profileIndex); } + /** + * @param {number} sourceProfileIndex + * @param {number} destinationProfileIndex + */ async copyProfile(sourceProfileIndex, destinationProfileIndex) { const sourceProfile = this._getProfile(sourceProfileIndex); if (sourceProfile === null || !this._getProfile(destinationProfileIndex)) { return; } @@ -140,9 +182,12 @@ export class ProfileController { await this._settingsController.refresh(); } + /** + * @param {number} profileIndex + */ async duplicateProfile(profileIndex) { const profile = this._getProfile(profileIndex); - if (this.profile === null) { return; } + if (profile === null) { return; } // Create new profile const newProfile = clone(profile); @@ -169,6 +214,9 @@ export class ProfileController { this._settingsController.profileIndex = index; } + /** + * @param {number} profileIndex + */ async deleteProfile(profileIndex) { const profile = this._getProfile(profileIndex); if (profile === null || this.profileCount <= 1) { return; } @@ -178,6 +226,7 @@ export class ProfileController { const settingsProfileIndex = this._settingsController.profileIndex; // Construct settings modifications + /** @type {import('settings-modifications').Modification[]} */ const modifications = [{ action: 'splice', path: 'profiles', @@ -225,6 +274,10 @@ export class ProfileController { await this._settingsController.modifyGlobalSettings(modifications); } + /** + * @param {number} index1 + * @param {number} index2 + */ async swapProfiles(index1, index2) { const profile1 = this._getProfile(index1); const profile2 = this._getProfile(index2); @@ -238,6 +291,7 @@ export class ProfileController { const settingsProfileIndexNew = this._getSwappedValue(settingsProfileIndex, index1, index2); // Construct settings modifications + /** @type {import('settings-modifications').Modification[]} */ const modifications = [{ action: 'swap', path1: `profiles[${index1}]`, @@ -278,15 +332,21 @@ export class ProfileController { } } + /** + * @param {number} profileIndex + */ openDeleteProfileModal(profileIndex) { const profile = this._getProfile(profileIndex); if (profile === null || this.profileCount <= 1) { return; } - this._removeProfileNameElement.textContent = profile.name; - this._profileRemoveModal.node.dataset.profileIndex = `${profileIndex}`; - this._profileRemoveModal.setVisible(true); + /** @type {HTMLElement} */ (this._removeProfileNameElement).textContent = profile.name; + /** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).node.dataset.profileIndex = `${profileIndex}`; + /** @type {import('./modal.js').Modal} */ (this._profileRemoveModal).setVisible(true); } + /** + * @param {number} profileIndex + */ openCopyProfileModal(profileIndex) { const profile = this._getProfile(profileIndex); if (profile === null || this.profileCount <= 1) { return; } @@ -301,16 +361,20 @@ export class ProfileController { } const profileIndexString = `${profileIndex}`; - for (const option of this._profileCopySourceSelect.querySelectorAll('option')) { + const select = /** @type {HTMLSelectElement} */ (this._profileCopySourceSelect); + for (const option of select.querySelectorAll('option')) { const {value} = option; option.disabled = (value === profileIndexString); } - this._profileCopySourceSelect.value = `${copyFromIndex}`; + select.value = `${copyFromIndex}`; - this._profileCopyModal.node.dataset.profileIndex = `${profileIndex}`; - this._profileCopyModal.setVisible(true); + /** @type {import('./modal.js').Modal} */ (this._profileCopyModal).node.dataset.profileIndex = `${profileIndex}`; + /** @type {import('./modal.js').Modal} */ (this._profileCopyModal).setVisible(true); } + /** + * @param {number} profileIndex + */ openProfileConditionsModal(profileIndex) { const profile = this._getProfile(profileIndex); if (profile === null) { return; } @@ -328,6 +392,7 @@ export class ProfileController { // Private + /** */ async _onOptionsChanged() { // Update state const {profiles, profileCurrent} = await this._settingsController.getOptionsFull(); @@ -339,8 +404,8 @@ export class ProfileController { // Udpate UI this._updateProfileSelectOptions(); - this._profileActiveSelect.value = `${profileCurrent}`; - this._profileTargetSelect.value = `${settingsProfileIndex}`; + /** @type {HTMLSelectElement} */ (this._profileActiveSelect).value = `${profileCurrent}`; + /** @type {HTMLSelectElement} */ (this._profileTargetSelect).value = `${settingsProfileIndex}`; // Update profile conditions this._profileConditionsUI.cleanup(); @@ -361,51 +426,65 @@ export class ProfileController { } } + /** + * @param {Event} e + */ _onProfileActiveChange(e) { - const value = this._tryGetValidProfileIndex(e.currentTarget.value); + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const value = this._tryGetValidProfileIndex(element.value); if (value === null) { return; } this.setDefaultProfile(value); } + /** + * @param {Event} e + */ _onProfileTargetChange(e) { - const value = this._tryGetValidProfileIndex(e.currentTarget.value); + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const value = this._tryGetValidProfileIndex(element.value); if (value === null) { return; } this._settingsController.profileIndex = value; } + /** */ _onAdd() { this.duplicateProfile(this._settingsController.profileIndex); } + /** */ _onDeleteConfirm() { - const modal = this._profileRemoveModal; + const modal = /** @type {import('./modal.js').Modal} */ (this._profileRemoveModal); modal.setVisible(false); const {node} = modal; - let profileIndex = node.dataset.profileIndex; + const profileIndex = node.dataset.profileIndex; delete node.dataset.profileIndex; - profileIndex = this._tryGetValidProfileIndex(profileIndex); - if (profileIndex === null) { return; } + const validProfileIndex = this._tryGetValidProfileIndex(profileIndex); + if (validProfileIndex === null) { return; } - this.deleteProfile(profileIndex); + this.deleteProfile(validProfileIndex); } + /** */ _onCopyConfirm() { - const modal = this._profileCopyModal; + const modal = /** @type {import('./modal.js').Modal} */ (this._profileCopyModal); modal.setVisible(false); const {node} = modal; - let destinationProfileIndex = node.dataset.profileIndex; + const destinationProfileIndex = node.dataset.profileIndex; delete node.dataset.profileIndex; - destinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex); - if (destinationProfileIndex === null) { return; } + const validDestinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex); + if (validDestinationProfileIndex === null) { return; } - const sourceProfileIndex = this._tryGetValidProfileIndex(this._profileCopySourceSelect.value); + const sourceProfileIndex = this._tryGetValidProfileIndex(/** @type {HTMLSelectElement} */ (this._profileCopySourceSelect).value); if (sourceProfileIndex === null) { return; } - this.copyProfile(sourceProfileIndex, destinationProfileIndex); + this.copyProfile(sourceProfileIndex, validDestinationProfileIndex); } + /** + * @param {import('profile-conditions-ui').ConditionGroupCountChangedEvent} details + */ _onConditionGroupCountChanged({count, profileIndex}) { if (profileIndex >= 0 && profileIndex < this._profileEntryList.length) { const profileEntry = this._profileEntryList[profileIndex]; @@ -413,15 +492,19 @@ export class ProfileController { } } + /** + * @param {number} profileIndex + */ _addProfileEntry(profileIndex) { const profile = this._profiles[profileIndex]; - const node = this._settingsController.instantiateTemplate('profile-entry'); - const entry = new ProfileEntry(this, node); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('profile-entry')); + const entry = new ProfileEntry(this, node, profile, profileIndex); this._profileEntryList.push(entry); - entry.prepare(profile, profileIndex); - this._profileEntryListContainer.appendChild(node); + entry.prepare(); + /** @type {HTMLElement} */ (this._profileEntryListContainer).appendChild(node); } + /** */ _updateProfileSelectOptions() { for (const select of this._getAllProfileSelects()) { const fragment = document.createDocumentFragment(); @@ -437,6 +520,10 @@ export class ProfileController { } } + /** + * @param {number} index + * @param {string} name + */ _updateSelectName(index, name) { const optionValue = `${index}`; for (const select of this._getAllProfileSelects()) { @@ -448,14 +535,21 @@ export class ProfileController { } } + /** + * @returns {HTMLSelectElement[]} + */ _getAllProfileSelects() { return [ - this._profileActiveSelect, - this._profileTargetSelect, - this._profileCopySourceSelect + /** @type {HTMLSelectElement} */ (this._profileActiveSelect), + /** @type {HTMLSelectElement} */ (this._profileTargetSelect), + /** @type {HTMLSelectElement} */ (this._profileCopySourceSelect) ]; } + /** + * @param {string|undefined} stringValue + * @returns {?number} + */ _tryGetValidProfileIndex(stringValue) { if (typeof stringValue !== 'string') { return null; } const intValue = parseInt(stringValue, 10); @@ -467,6 +561,12 @@ export class ProfileController { ); } + /** + * @param {string} name + * @param {import('settings').Profile[]} profiles + * @param {number} maxUniqueAttempts + * @returns {string} + */ _createCopyName(name, profiles, maxUniqueAttempts) { let space, index, prefix, suffix; const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); @@ -502,44 +602,80 @@ export class ProfileController { } } + /** + * @template T + * @param {T} currentValue + * @param {T} value1 + * @param {T} value2 + * @returns {T} + */ _getSwappedValue(currentValue, value1, value2) { if (currentValue === value1) { return value2; } if (currentValue === value2) { return value1; } return currentValue; } + /** + * @param {number} profileIndex + * @returns {?import('settings').Profile} + */ _getProfile(profileIndex) { return (profileIndex >= 0 && profileIndex < this._profiles.length ? this._profiles[profileIndex] : null); } + /** + * @param {number} profileIndex + * @returns {?ProfileEntry} + */ _getProfileEntry(profileIndex) { return (profileIndex >= 0 && profileIndex < this._profileEntryList.length ? this._profileEntryList[profileIndex] : null); } + /** + * @param {Element} node1 + * @param {Element} node2 + */ _swapDomNodes(node1, node2) { const parent1 = node1.parentNode; const parent2 = node2.parentNode; const next1 = node1.nextSibling; const next2 = node2.nextSibling; - if (node2 !== next1) { parent1.insertBefore(node2, next1); } - if (node1 !== next2) { parent2.insertBefore(node1, next2); } + if (node2 !== next1 && parent1 !== null) { parent1.insertBefore(node2, next1); } + if (node1 !== next2 && parent2 !== null) { parent2.insertBefore(node1, next2); } } } class ProfileEntry { - constructor(profileController, node) { + /** + * @param {ProfileController} profileController + * @param {HTMLElement} node + * @param {import('settings').Profile} profile + * @param {number} index + */ + constructor(profileController, node, profile, index) { + /** @type {ProfileController} */ this._profileController = profileController; + /** @type {HTMLElement} */ this._node = node; - this._profile = null; - this._index = 0; - this._isDefaultRadio = null; - this._nameInput = null; - this._countLink = null; - this._countText = null; - this._menuButton = null; + /** @type {import('settings').Profile} */ + this._profile = profile; + /** @type {number} */ + this._index = index; + /** @type {HTMLInputElement} */ + this._isDefaultRadio = /** @type {HTMLInputElement} */ (node.querySelector('.profile-entry-is-default-radio')); + /** @type {HTMLInputElement} */ + this._nameInput = /** @type {HTMLInputElement} */ (node.querySelector('.profile-entry-name-input')); + /** @type {HTMLElement} */ + this._countLink = /** @type {HTMLElement} */ (node.querySelector('.profile-entry-condition-count-link')); + /** @type {HTMLElement} */ + this._countText = /** @type {HTMLElement} */ (node.querySelector('.profile-entry-condition-count')); + /** @type {HTMLButtonElement} */ + this._menuButton = /** @type {HTMLButtonElement} */ (node.querySelector('.profile-entry-menu-button')); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** @type {number} */ get index() { return this._index; } @@ -548,21 +684,13 @@ class ProfileEntry { this._index = value; } + /** @type {HTMLElement} */ get node() { return this._node; } - prepare(profile, index) { - this._profile = profile; - this._index = index; - - const node = this._node; - this._isDefaultRadio = node.querySelector('.profile-entry-is-default-radio'); - this._nameInput = node.querySelector('.profile-entry-name-input'); - this._countLink = node.querySelector('.profile-entry-condition-count-link'); - this._countText = node.querySelector('.profile-entry-condition-count'); - this._menuButton = node.querySelector('.profile-entry-menu-button'); - + /** */ + prepare() { this.updateState(); this._eventListeners.addEventListener(this._isDefaultRadio, 'change', this._onIsDefaultRadioChange.bind(this), false); @@ -572,6 +700,7 @@ class ProfileEntry { this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._node.parentNode !== null) { @@ -579,41 +708,63 @@ class ProfileEntry { } } + /** + * @param {string} value + */ setName(value) { if (this._nameInput.value === value) { return; } this._nameInput.value = value; } + /** + * @param {boolean} value + */ setIsDefault(value) { this._isDefaultRadio.checked = value; } + /** */ updateState() { this._nameInput.value = this._profile.name; this._countText.textContent = `${this._profile.conditionGroups.length}`; this._isDefaultRadio.checked = (this._index === this._profileController.profileCurrentIndex); } + /** + * @param {number} count + */ setConditionGroupsCount(count) { this._countText.textContent = `${count}`; } // Private + /** + * @param {Event} e + */ _onIsDefaultRadioChange(e) { - if (!e.currentTarget.checked) { return; } + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + if (!element.checked) { return; } this._profileController.setDefaultProfile(this._index); } + /** + * @param {Event} e + */ _onNameInputInput(e) { - const name = e.currentTarget.value; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + const name = element.value; this._profileController.setProfileName(this._index, name); } + /** */ _onConditionsCountLinkClick() { this._profileController.openProfileConditionsModal(this._index); } + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const bodyNode = e.detail.menu.bodyNode; const count = this._profileController.profileCount; @@ -623,6 +774,9 @@ class ProfileEntry { this._setMenuActionEnabled(bodyNode, 'delete', count > 1); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'moveUp': @@ -646,8 +800,13 @@ class ProfileEntry { } } + /** + * @param {Element} menu + * @param {string} action + * @param {boolean} enabled + */ _setMenuActionEnabled(menu, action, enabled) { - const element = menu.querySelector(`[data-menu-action="${action}"]`); + const element = /** @type {HTMLButtonElement} */ (menu.querySelector(`[data-menu-action="${action}"]`)); if (element === null) { return; } element.disabled = !enabled; } diff --git a/ext/js/pages/settings/recommended-permissions-controller.js b/ext/js/pages/settings/recommended-permissions-controller.js index e04dbdf7..b19311aa 100644 --- a/ext/js/pages/settings/recommended-permissions-controller.js +++ b/ext/js/pages/settings/recommended-permissions-controller.js @@ -19,13 +19,21 @@ import {EventListenerCollection} from '../../core.js'; export class RecommendedPermissionsController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?NodeListOf<HTMLInputElement>} */ this._originToggleNodes = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLElement} */ this._errorContainer = null; } + /** */ async prepare() { this._originToggleNodes = document.querySelectorAll('.recommended-permissions-toggle'); this._errorContainer = document.querySelector('#recommended-permissions-error'); @@ -39,35 +47,53 @@ export class RecommendedPermissionsController { // Private + /** + * @param {import('settings-controller').PermissionsChangedEvent} details + */ _onPermissionsChanged({permissions}) { this._eventListeners.removeAllEventListeners(); const originsSet = new Set(permissions.origins); - for (const node of this._originToggleNodes) { - node.checked = originsSet.has(node.dataset.origin); + if (this._originToggleNodes !== null) { + for (const node of this._originToggleNodes) { + const {origin} = node.dataset; + node.checked = typeof origin === 'string' && originsSet.has(origin); + } } } + /** + * @param {Event} e + */ _onOriginToggleChange(e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); const value = node.checked; node.checked = !value; const {origin} = node.dataset; + if (typeof origin !== 'string') { return; } this._setOriginPermissionEnabled(origin, value); } + /** */ async _updatePermissions() { const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); this._onPermissionsChanged({permissions}); } + /** + * @param {string} origin + * @param {boolean} enabled + * @returns {Promise<boolean>} + */ async _setOriginPermissionEnabled(origin, enabled) { let added = false; try { added = await this._settingsController.permissionsUtil.setPermissionsGranted({origins: [origin]}, enabled); } catch (e) { - this._errorContainer.hidden = false; - this._errorContainer.textContent = e.message; + if (this._errorContainer !== null) { + this._errorContainer.hidden = false; + this._errorContainer.textContent = e instanceof Error ? e.message : `${e}`; + } } if (!added) { return false; } await this._updatePermissions(); diff --git a/ext/js/pages/settings/scan-inputs-controller.js b/ext/js/pages/settings/scan-inputs-controller.js index 252e7238..53423bdc 100644 --- a/ext/js/pages/settings/scan-inputs-controller.js +++ b/ext/js/pages/settings/scan-inputs-controller.js @@ -17,26 +17,37 @@ */ import {EventListenerCollection} from '../../core.js'; +import {DocumentUtil} from '../../dom/document-util.js'; import {yomitan} from '../../yomitan.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; export class ScanInputsController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?import('environment').OperatingSystem} */ this._os = null; + /** @type {?HTMLElement} */ this._container = null; + /** @type {?HTMLButtonElement} */ this._addButton = null; + /** @type {?NodeListOf<HTMLElement>} */ this._scanningInputCountNodes = null; + /** @type {ScanInputField[]} */ this._entries = []; } + /** */ async prepare() { const {platform: {os}} = await yomitan.api.getEnvironmentInfo(); this._os = os; - this._container = document.querySelector('#scan-input-list'); - this._addButton = document.querySelector('#scan-input-add'); - this._scanningInputCountNodes = document.querySelectorAll('.scanning-input-count'); + this._container = /** @type {HTMLElement} */ (document.querySelector('#scan-input-list')); + this._addButton = /** @type {HTMLButtonElement} */ (document.querySelector('#scan-input-add')); + this._scanningInputCountNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.scanning-input-count')); this._addButton.addEventListener('click', this._onAddButtonClick.bind(this), false); this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this)); @@ -45,6 +56,10 @@ export class ScanInputsController { this.refresh(); } + /** + * @param {number} index + * @returns {boolean} + */ removeInput(index) { if (index < 0 || index >= this._entries.length) { return false; } const input = this._entries[index]; @@ -64,6 +79,12 @@ export class ScanInputsController { return true; } + /** + * @param {number} index + * @param {string} property + * @param {unknown} value + * @param {boolean} event + */ async setProperty(index, property, value, event) { const path = `scanning.inputs[${index}].${property}`; await this._settingsController.setProfileSetting(path, value); @@ -72,22 +93,34 @@ export class ScanInputsController { } } + /** + * @param {string} name + * @returns {Element} + */ instantiateTemplate(name) { return this._settingsController.instantiateTemplate(name); } + /** */ async refresh() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } // Private + /** + * @param {import('settings-controller').ScanInputsChangedEvent} details + */ _onScanInputsChanged({source}) { if (source === this) { return; } this.refresh(); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { const {inputs} = options.scanning; @@ -103,6 +136,9 @@ export class ScanInputsController { this._updateCounts(); } + /** + * @param {MouseEvent} e + */ _onAddButtonClick(e) { e.preventDefault(); @@ -119,34 +155,51 @@ export class ScanInputsController { }]); // Scroll to bottom - const button = e.currentTarget; - const modalContainer = button.closest('.modal'); - const scrollContainer = modalContainer.querySelector('.modal-body'); + const button = /** @type {HTMLElement} */ (e.currentTarget); + const modalContainer = /** @type {HTMLElement} */ (button.closest('.modal')); + const scrollContainer = /** @type {HTMLElement} */ (modalContainer.querySelector('.modal-body')); scrollContainer.scrollTop = scrollContainer.scrollHeight; } + /** + * @param {number} index + * @param {import('settings').ScanningInput} scanningInput + */ _addOption(index, scanningInput) { + if (this._os === null || this._container === null) { return; } const field = new ScanInputField(this, index, this._os); this._entries.push(field); field.prepare(this._container, scanningInput); } + /** */ _updateCounts() { const stringValue = `${this._entries.length}`; - for (const node of this._scanningInputCountNodes) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._scanningInputCountNodes)) { node.textContent = stringValue; } } + /** + * @param {import('settings-modifications').Modification[]} targets + */ async _modifyProfileSettings(targets) { await this._settingsController.modifyProfileSettings(targets); this._triggerScanInputsChanged(); } + /** */ _triggerScanInputsChanged() { - this._settingsController.trigger('scanInputsChanged', {source: this}); + /** @type {import('settings-controller').ScanInputsChangedEvent} */ + const event = {source: this}; + this._settingsController.trigger('scanInputsChanged', event); } + /** + * @param {string} include + * @param {string} exclude + * @returns {import('settings').ScanningInput} + */ static createDefaultMouseInput(include, exclude) { return { include, @@ -172,16 +225,29 @@ export class ScanInputsController { } class ScanInputField { + /** + * @param {ScanInputsController} parent + * @param {number} index + * @param {import('environment').OperatingSystem} os + */ constructor(parent, index, os) { + /** @type {ScanInputsController} */ this._parent = parent; + /** @type {number} */ this._index = index; + /** @type {import('environment').OperatingSystem} */ this._os = os; + /** @type {?HTMLElement} */ this._node = null; + /** @type {?KeyboardMouseInputField} */ this._includeInputField = null; + /** @type {?KeyboardMouseInputField} */ this._excludeInputField = null; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); } + /** @type {number} */ get index() { return this._index; } @@ -191,16 +257,20 @@ class ScanInputField { this._updateDataSettingTargets(); } + /** + * @param {HTMLElement} container + * @param {import('settings').ScanningInput} scanningInput + */ prepare(container, scanningInput) { const {include, exclude, options: {showAdvanced}} = scanningInput; - const node = this._parent.instantiateTemplate('scan-input'); - const includeInputNode = node.querySelector('.scan-input-field[data-property=include]'); - const includeMouseButton = node.querySelector('.mouse-button[data-property=include]'); - const excludeInputNode = node.querySelector('.scan-input-field[data-property=exclude]'); - const excludeMouseButton = node.querySelector('.mouse-button[data-property=exclude]'); - const removeButton = node.querySelector('.scan-input-remove'); - const menuButton = node.querySelector('.scanning-input-menu-button'); + const node = /** @type {HTMLElement} */ (this._parent.instantiateTemplate('scan-input')); + const includeInputNode = /** @type {HTMLInputElement} */ (node.querySelector('.scan-input-field[data-property=include]')); + const includeMouseButton = /** @type {HTMLButtonElement} */ (node.querySelector('.mouse-button[data-property=include]')); + const excludeInputNode = /** @type {HTMLInputElement} */ (node.querySelector('.scan-input-field[data-property=exclude]')); + const excludeMouseButton = /** @type {HTMLButtonElement} */ (node.querySelector('.mouse-button[data-property=exclude]')); + const removeButton = /** @type {HTMLButtonElement} */ (node.querySelector('.scan-input-remove')); + const menuButton = /** @type {HTMLButtonElement} */ (node.querySelector('.scanning-input-menu-button')); node.dataset.showAdvanced = `${showAdvanced}`; @@ -226,6 +296,7 @@ class ScanInputField { this._updateDataSettingTargets(); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._includeInputField !== null) { @@ -241,26 +312,38 @@ class ScanInputField { // Private + /** + * @param {import('keyboard-mouse-input-field').ChangeEvent} details + */ _onIncludeValueChange({modifiers}) { - modifiers = this._joinModifiers(modifiers); - this._parent.setProperty(this._index, 'include', modifiers, true); + const modifiers2 = this._joinModifiers(modifiers); + this._parent.setProperty(this._index, 'include', modifiers2, true); } + /** + * @param {import('keyboard-mouse-input-field').ChangeEvent} details + */ _onExcludeValueChange({modifiers}) { - modifiers = this._joinModifiers(modifiers); - this._parent.setProperty(this._index, 'exclude', modifiers, true); + const modifiers2 = this._joinModifiers(modifiers); + this._parent.setProperty(this._index, 'exclude', modifiers2, true); } + /** + * @param {MouseEvent} e + */ _onRemoveClick(e) { e.preventDefault(); this._removeSelf(); } + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const bodyNode = e.detail.menu.bodyNode; - const showAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="showAdvanced"]'); - const hideAdvanced = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideAdvanced"]'); - const advancedVisible = (this._node.dataset.showAdvanced === 'true'); + const showAdvanced = /** @type {?HTMLElement} */ (bodyNode.querySelector('.popup-menu-item[data-menu-action="showAdvanced"]')); + const hideAdvanced = /** @type {?HTMLElement} */ (bodyNode.querySelector('.popup-menu-item[data-menu-action="hideAdvanced"]')); + const advancedVisible = (this._node !== null && this._node.dataset.showAdvanced === 'true'); if (showAdvanced !== null) { showAdvanced.hidden = advancedVisible; } @@ -269,6 +352,9 @@ class ScanInputField { } } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'remove': @@ -281,40 +367,67 @@ class ScanInputField { this._setAdvancedOptionsVisible(false); break; case 'clearInputs': - this._includeInputField.clearInputs(); - this._excludeInputField.clearInputs(); + /** @type {KeyboardMouseInputField} */ (this._includeInputField).clearInputs(); + /** @type {KeyboardMouseInputField} */ (this._excludeInputField).clearInputs(); break; } } + /** + * @param {string} pointerType + * @returns {boolean} + */ _isPointerTypeSupported(pointerType) { if (this._node === null) { return false; } - const node = this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`); + const node = /** @type {?HTMLInputElement} */ (this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`)); return node !== null && node.checked; } + /** */ _updateDataSettingTargets() { + if (this._node === null) { return; } const index = this._index; - for (const typeCheckbox of this._node.querySelectorAll('.scan-input-settings-checkbox')) { + for (const typeCheckbox of /** @type {NodeListOf<HTMLElement>} */ (this._node.querySelectorAll('.scan-input-settings-checkbox'))) { const {property} = typeCheckbox.dataset; typeCheckbox.dataset.setting = `scanning.inputs[${index}].${property}`; } } + /** */ _removeSelf() { this._parent.removeInput(this._index); } + /** + * @param {boolean} showAdvanced + */ _setAdvancedOptionsVisible(showAdvanced) { showAdvanced = !!showAdvanced; - this._node.dataset.showAdvanced = `${showAdvanced}`; + if (this._node !== null) { + this._node.dataset.showAdvanced = `${showAdvanced}`; + } this._parent.setProperty(this._index, 'options.showAdvanced', showAdvanced, false); } + /** + * @param {string} modifiersString + * @returns {import('input').Modifier[]} + */ _splitModifiers(modifiersString) { - return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + /** @type {import('input').Modifier[]} */ + const results = []; + for (const modifier of modifiersString.split(/[,;\s]+/)) { + const modifier2 = DocumentUtil.normalizeModifier(modifier.trim().toLowerCase()); + if (modifier2 === null) { continue; } + results.push(modifier2); + } + return results; } + /** + * @param {import('input').Modifier[]} modifiersArray + * @returns {string} + */ _joinModifiers(modifiersArray) { return modifiersArray.join(', '); } diff --git a/ext/js/pages/settings/scan-inputs-simple-controller.js b/ext/js/pages/settings/scan-inputs-simple-controller.js index 112c03a9..8d52af61 100644 --- a/ext/js/pages/settings/scan-inputs-simple-controller.js +++ b/ext/js/pages/settings/scan-inputs-simple-controller.js @@ -21,17 +21,26 @@ import {yomitan} from '../../yomitan.js'; import {ScanInputsController} from './scan-inputs-controller.js'; export class ScanInputsSimpleController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?HTMLInputElement} */ this._middleMouseButtonScan = null; + /** @type {?HTMLSelectElement} */ this._mainScanModifierKeyInput = null; + /** @type {boolean} */ this._mainScanModifierKeyInputHasOther = false; + /** @type {HotkeyUtil} */ this._hotkeyUtil = new HotkeyUtil(); } + /** */ async prepare() { - this._middleMouseButtonScan = document.querySelector('#middle-mouse-button-scan'); - this._mainScanModifierKeyInput = document.querySelector('#main-scan-modifier-key'); + this._middleMouseButtonScan = /** @type {HTMLInputElement} */ (document.querySelector('#middle-mouse-button-scan')); + this._mainScanModifierKeyInput = /** @type {HTMLSelectElement} */ (document.querySelector('#main-scan-modifier-key')); const {platform: {os}} = await yomitan.api.getEnvironmentInfo(); this._hotkeyUtil.os = os; @@ -40,27 +49,36 @@ export class ScanInputsSimpleController { this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther); const options = await this._settingsController.getOptions(); + const optionsContext = this._settingsController.getOptionsContext(); this._middleMouseButtonScan.addEventListener('change', this.onMiddleMouseButtonScanChange.bind(this), false); this._mainScanModifierKeyInput.addEventListener('change', this._onMainScanModifierKeyInputChange.bind(this), false); this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this)); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); - this._onOptionsChanged({options}); + this._onOptionsChanged({options, optionsContext}); } + /** */ async refresh() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } // Private + /** + * @param {import('settings-controller').ScanInputsChangedEvent} details + */ _onScanInputsChanged({source}) { if (source === this) { return; } this.refresh(); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { const {scanning: {inputs}} = options; const middleMouseSupportedIndex = this._getIndexOfMiddleMouseButtonScanInput(inputs); @@ -87,27 +105,39 @@ export class ScanInputsSimpleController { this._setHasMainScanInput(hasMainScanInput); - this._middleMouseButtonScan.checked = middleMouseSupported; - this._mainScanModifierKeyInput.value = mainScanInput; + /** @type {HTMLInputElement} */ (this._middleMouseButtonScan).checked = middleMouseSupported; + /** @type {HTMLSelectElement} */ (this._mainScanModifierKeyInput).value = mainScanInput; } + /** + * @param {Event} e + */ onMiddleMouseButtonScanChange(e) { - const middleMouseSupported = e.currentTarget.checked; + const element = /** @type {HTMLInputElement} */ (e.currentTarget); + const middleMouseSupported = element.checked; this._setMiddleMouseSuppported(middleMouseSupported); } + /** + * @param {Event} e + */ _onMainScanModifierKeyInputChange(e) { - const mainScanKey = e.currentTarget.value; + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + const mainScanKey = element.value; if (mainScanKey === 'other') { return; } const mainScanInputs = (mainScanKey === 'none' ? [] : [mainScanKey]); this._setMainScanInputs(mainScanInputs); } + /** + * @param {HTMLSelectElement} select + * @param {boolean} hasOther + */ _populateSelect(select, hasOther) { const modifierKeys = [ {value: 'none', name: 'No key'} ]; - for (const value of ['alt', 'ctrl', 'shift', 'meta']) { + for (const value of /** @type {import('input').ModifierKey[]} */ (['alt', 'ctrl', 'shift', 'meta'])) { const name = this._hotkeyUtil.getModifierDisplayValue(value); modifierKeys.push({value, name}); } @@ -127,10 +157,17 @@ export class ScanInputsSimpleController { select.appendChild(fragment); } + /** + * @param {string} value + * @returns {string[]} + */ _splitValue(value) { return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); } + /** + * @param {boolean} value + */ async _setMiddleMouseSuppported(value) { // Find target index const options = await this._settingsController.getOptions(); @@ -163,8 +200,11 @@ export class ScanInputsSimpleController { } } + /** + * @param {string[]} value + */ async _setMainScanInputs(value) { - value = value.join(', '); + const value2 = value.join(', '); // Find target index const options = await this._settingsController.getOptions(); @@ -175,7 +215,7 @@ export class ScanInputsSimpleController { if (index < 0) { // Add new - const input = ScanInputsController.createDefaultMouseInput(value, 'mouse0'); + const input = ScanInputsController.createDefaultMouseInput(value2, 'mouse0'); await this._modifyProfileSettings([{ action: 'splice', path: 'scanning.inputs', @@ -188,16 +228,25 @@ export class ScanInputsSimpleController { await this._modifyProfileSettings([{ action: 'set', path: `scanning.inputs[${index}].include`, - value + value: value2 }]); } } + /** + * @param {import('settings-modifications').Modification[]} targets + */ async _modifyProfileSettings(targets) { await this._settingsController.modifyProfileSettings(targets); - this._settingsController.trigger('scanInputsChanged', {source: this}); + /** @type {import('settings-controller').ScanInputsChangedEvent} */ + const event = {source: this}; + this._settingsController.trigger('scanInputsChanged', event); } + /** + * @param {import('settings').ScanningInput[]} inputs + * @returns {number} + */ _getIndexOfMainScanInput(inputs) { for (let i = 0, ii = inputs.length; i < ii; ++i) { const {include, exclude, types: {mouse}} = inputs[i]; @@ -218,6 +267,10 @@ export class ScanInputsSimpleController { return -1; } + /** + * @param {import('settings').ScanningInput[]} inputs + * @returns {number} + */ _getIndexOfMiddleMouseButtonScanInput(inputs) { for (let i = 0, ii = inputs.length; i < ii; ++i) { const {include, exclude, types: {mouse}} = inputs[i]; @@ -234,13 +287,22 @@ export class ScanInputsSimpleController { return -1; } + /** + * @param {string} input + * @returns {boolean} + */ _isMouseInput(input) { return /^mouse\d+$/.test(input); } + /** + * @param {boolean} hasMainScanInput + */ _setHasMainScanInput(hasMainScanInput) { if (this._mainScanModifierKeyInputHasOther !== hasMainScanInput) { return; } this._mainScanModifierKeyInputHasOther = !hasMainScanInput; - this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther); + if (this._mainScanModifierKeyInput !== null) { + this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther); + } } } diff --git a/ext/js/pages/settings/secondary-search-dictionary-controller.js b/ext/js/pages/settings/secondary-search-dictionary-controller.js index cc873901..b20bd475 100644 --- a/ext/js/pages/settings/secondary-search-dictionary-controller.js +++ b/ext/js/pages/settings/secondary-search-dictionary-controller.js @@ -20,16 +20,25 @@ import {EventListenerCollection} from '../../core.js'; import {yomitan} from '../../yomitan.js'; export class SecondarySearchDictionaryController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?import('core').TokenObject} */ this._getDictionaryInfoToken = null; + /** @type {Map<string, import('dictionary-importer').Summary>} */ this._dictionaryInfoMap = new Map(); + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLElement} */ this._container = null; } + /** */ async prepare() { - this._container = document.querySelector('#secondary-search-dictionary-list'); + this._container = /** @type {HTMLElement} */ (document.querySelector('#secondary-search-dictionary-list')); await this._onDatabaseUpdated(); @@ -40,7 +49,9 @@ export class SecondarySearchDictionaryController { // Private + /** */ async _onDatabaseUpdated() { + /** @type {?import('core').TokenObject} */ const token = {}; this._getDictionaryInfoToken = token; const dictionaries = await this._settingsController.getDictionaryInfo(); @@ -52,10 +63,12 @@ export class SecondarySearchDictionaryController { this._dictionaryInfoMap.set(entry.title, entry); } - const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + await this._onDictionarySettingsReordered(); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { this._eventListeners.removeAllEventListeners(); @@ -67,31 +80,38 @@ export class SecondarySearchDictionaryController { const dictionaryInfo = this._dictionaryInfoMap.get(name); if (typeof dictionaryInfo === 'undefined') { continue; } - const node = this._settingsController.instantiateTemplate('secondary-search-dictionary'); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('secondary-search-dictionary')); fragment.appendChild(node); - const nameNode = node.querySelector('.dictionary-title'); + const nameNode = /** @type {HTMLElement} */ (node.querySelector('.dictionary-title')); nameNode.textContent = name; - const versionNode = node.querySelector('.dictionary-version'); + const versionNode = /** @type {HTMLElement} */ (node.querySelector('.dictionary-version')); versionNode.textContent = `rev.${dictionaryInfo.revision}`; - const toggle = node.querySelector('.dictionary-allow-secondary-searches'); + const toggle = /** @type {HTMLElement} */ (node.querySelector('.dictionary-allow-secondary-searches')); toggle.dataset.setting = `dictionaries[${i}].allowSecondarySearches`; this._eventListeners.addEventListener(toggle, 'settingChanged', this._onEnabledChanged.bind(this, node), false); } - this._container.textContent = ''; - this._container.appendChild(fragment); + const container = /** @type {HTMLElement} */ (this._container); + container.textContent = ''; + container.appendChild(fragment); } + /** + * @param {HTMLElement} node + * @param {import('dom-data-binder').SettingChangedEvent} e + */ _onEnabledChanged(node, e) { const {detail: {value}} = e; node.dataset.enabled = `${value}`; } + /** */ async _onDictionarySettingsReordered() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } } diff --git a/ext/js/pages/settings/sentence-termination-characters-controller.js b/ext/js/pages/settings/sentence-termination-characters-controller.js index f901b82c..80c4cdbe 100644 --- a/ext/js/pages/settings/sentence-termination-characters-controller.js +++ b/ext/js/pages/settings/sentence-termination-characters-controller.js @@ -19,26 +19,38 @@ import {EventListenerCollection} from '../../core.js'; export class SentenceTerminationCharactersController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {SentenceTerminationCharacterEntry[]} */ this._entries = []; + /** @type {?HTMLButtonElement} */ this._addButton = null; + /** @type {?HTMLButtonElement} */ this._resetButton = null; + /** @type {?HTMLElement} */ this._listTable = null; + /** @type {?HTMLElement} */ this._listContainer = null; + /** @type {?HTMLElement} */ this._emptyIndicator = null; } + /** @type {import('./settings-controller.js').SettingsController} */ get settingsController() { return this._settingsController; } + /** */ async prepare() { - this._addButton = document.querySelector('#sentence-termination-character-list-add'); - this._resetButton = document.querySelector('#sentence-termination-character-list-reset'); - this._listTable = document.querySelector('#sentence-termination-character-list-table'); - this._listContainer = document.querySelector('#sentence-termination-character-list'); - this._emptyIndicator = document.querySelector('#sentence-termination-character-list-empty'); + this._addButton = /** @type {HTMLButtonElement} */ (document.querySelector('#sentence-termination-character-list-add')); + this._resetButton = /** @type {HTMLButtonElement} */ (document.querySelector('#sentence-termination-character-list-reset')); + this._listTable = /** @type {HTMLElement} */ (document.querySelector('#sentence-termination-character-list-table')); + this._listContainer = /** @type {HTMLElement} */ (document.querySelector('#sentence-termination-character-list')); + this._emptyIndicator = /** @type {HTMLElement} */ (document.querySelector('#sentence-termination-character-list-empty')); this._addButton.addEventListener('click', this._onAddClick.bind(this)); this._resetButton.addEventListener('click', this._onResetClick.bind(this)); @@ -47,6 +59,9 @@ export class SentenceTerminationCharactersController { await this._updateOptions(); } + /** + * @param {import('settings').SentenceParsingTerminationCharacterOption} terminationCharacterEntry + */ async addEntry(terminationCharacterEntry) { const options = await this._settingsController.getOptions(); const {sentenceParsing: {terminationCharacters}} = options; @@ -62,6 +77,10 @@ export class SentenceTerminationCharactersController { await this._updateOptions(); } + /** + * @param {number} index + * @returns {Promise<boolean>} + */ async deleteEntry(index) { const options = await this._settingsController.getOptions(); const {sentenceParsing: {terminationCharacters}} = options; @@ -80,12 +99,19 @@ export class SentenceTerminationCharactersController { return true; } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async modifyProfileSettings(targets) { return await this._settingsController.modifyProfileSettings(targets); } // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { for (const entry of this._entries) { entry.cleanup(); @@ -94,29 +120,37 @@ export class SentenceTerminationCharactersController { this._entries = []; const {sentenceParsing: {terminationCharacters}} = options; + const listContainer = /** @type {HTMLElement} */ (this._listContainer); for (let i = 0, ii = terminationCharacters.length; i < ii; ++i) { const terminationCharacterEntry = terminationCharacters[i]; - const node = this._settingsController.instantiateTemplate('sentence-termination-character-entry'); - this._listContainer.appendChild(node); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('sentence-termination-character-entry')); + listContainer.appendChild(node); const entry = new SentenceTerminationCharacterEntry(this, terminationCharacterEntry, i, node); this._entries.push(entry); entry.prepare(); } - this._listTable.hidden = (terminationCharacters.length === 0); - this._emptyIndicator.hidden = (terminationCharacters.length !== 0); + /** @type {HTMLElement} */ (this._listTable).hidden = (terminationCharacters.length === 0); + /** @type {HTMLElement} */ (this._emptyIndicator).hidden = (terminationCharacters.length !== 0); } + /** + * @param {MouseEvent} e + */ _onAddClick(e) { e.preventDefault(); this._addNewEntry(); } + /** + * @param {MouseEvent} e + */ _onResetClick(e) { e.preventDefault(); this._reset(); } + /** */ async _addNewEntry() { const newEntry = { enabled: true, @@ -125,14 +159,17 @@ export class SentenceTerminationCharactersController { includeCharacterAtStart: false, includeCharacterAtEnd: false }; - return await this.addEntry(newEntry); + await this.addEntry(newEntry); } + /** */ async _updateOptions() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** */ async _reset() { const defaultOptions = await this._settingsController.getDefaultOptions(); const value = defaultOptions.profiles[0].options.sentenceParsing.terminationCharacters; @@ -142,28 +179,43 @@ export class SentenceTerminationCharactersController { } class SentenceTerminationCharacterEntry { + /** + * @param {SentenceTerminationCharactersController} parent + * @param {import('settings').SentenceParsingTerminationCharacterOption} data + * @param {number} index + * @param {HTMLElement} node + */ constructor(parent, data, index, node) { + /** @type {SentenceTerminationCharactersController} */ this._parent = parent; + /** @type {import('settings').SentenceParsingTerminationCharacterOption} */ this._data = data; + /** @type {number} */ this._index = index; + /** @type {HTMLElement} */ this._node = node; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLInputElement} */ this._character1Input = null; + /** @type {?HTMLInputElement} */ this._character2Input = null; + /** @type {string} */ this._basePath = `sentenceParsing.terminationCharacters[${this._index}]`; } + /** */ prepare() { const {enabled, character1, character2, includeCharacterAtStart, includeCharacterAtEnd} = this._data; const node = this._node; - const enabledToggle = node.querySelector('.sentence-termination-character-enabled'); - const typeSelect = node.querySelector('.sentence-termination-character-type'); - const character1Input = node.querySelector('.sentence-termination-character-input1'); - const character2Input = node.querySelector('.sentence-termination-character-input2'); - const includeAtStartCheckbox = node.querySelector('.sentence-termination-character-include-at-start'); - const includeAtEndheckbox = node.querySelector('.sentence-termination-character-include-at-end'); - const menuButton = node.querySelector('.sentence-termination-character-entry-button'); + const enabledToggle = /** @type {HTMLInputElement} */ (node.querySelector('.sentence-termination-character-enabled')); + const typeSelect = /** @type {HTMLSelectElement} */ (node.querySelector('.sentence-termination-character-type')); + const character1Input = /** @type {HTMLInputElement} */ (node.querySelector('.sentence-termination-character-input1')); + const character2Input = /** @type {HTMLInputElement} */ (node.querySelector('.sentence-termination-character-input2')); + const includeAtStartCheckbox = /** @type {HTMLInputElement} */ (node.querySelector('.sentence-termination-character-include-at-start')); + const includeAtEndheckbox = /** @type {HTMLInputElement} */ (node.querySelector('.sentence-termination-character-include-at-end')); + const menuButton = /** @type {HTMLButtonElement} */ (node.querySelector('.sentence-termination-character-entry-button')); this._character1Input = character1Input; this._character2Input = character2Input; @@ -188,6 +240,7 @@ class SentenceTerminationCharacterEntry { this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._node.parentNode !== null) { @@ -197,12 +250,20 @@ class SentenceTerminationCharacterEntry { // Private + /** + * @param {Event} e + */ _onTypeSelectChange(e) { - this._setHasCharacter2(e.currentTarget.value === 'quote'); + const element = /** @type {HTMLSelectElement} */ (e.currentTarget); + this._setHasCharacter2(element.value === 'quote'); } + /** + * @param {1|2} characterNumber + * @param {Event} e + */ _onCharacterChange(characterNumber, e) { - const node = e.currentTarget; + const node = /** @type {HTMLInputElement} */ (e.currentTarget); if (characterNumber === 2 && this._data.character2 === null) { node.value = ''; } @@ -211,6 +272,9 @@ class SentenceTerminationCharacterEntry { this._setCharacterValue(node, characterNumber, value); } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'delete': @@ -219,11 +283,16 @@ class SentenceTerminationCharacterEntry { } } + /** */ async _delete() { this._parent.deleteEntry(this._index); } + /** + * @param {boolean} has + */ async _setHasCharacter2(has) { + if (this._character2Input === null) { return; } const okay = await this._setCharacterValue(this._character2Input, 2, has ? this._data.character1 : null); if (okay) { const type = (!has ? 'terminator' : 'quote'); @@ -231,14 +300,24 @@ class SentenceTerminationCharacterEntry { } } + /** + * @param {HTMLInputElement} inputNode + * @param {1|2} characterNumber + * @param {?string} value + * @returns {Promise<boolean>} + */ async _setCharacterValue(inputNode, characterNumber, value) { - const pathEnd = `character${characterNumber}`; - const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.${pathEnd}`, value); + if (characterNumber === 1 && typeof value !== 'string') { value = ''; } + const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.character${characterNumber}`, value); const okay = !r[0].error; if (okay) { - this._data[pathEnd] = value; + if (characterNumber === 1) { + this._data.character1 = /** @type {string} */ (value); + } else { + this._data.character2 = value; + } } else { - value = this._data[pathEnd]; + value = characterNumber === 1 ? this._data.character1 : this._data.character2; } inputNode.value = (value !== null ? value : ''); return okay; diff --git a/ext/js/pages/settings/settings-controller.js b/ext/js/pages/settings/settings-controller.js index 83ccdb39..1b46c745 100644 --- a/ext/js/pages/settings/settings-controller.js +++ b/ext/js/pages/settings/settings-controller.js @@ -22,21 +22,33 @@ import {PermissionsUtil} from '../../data/permissions-util.js'; import {HtmlTemplateCollection} from '../../dom/html-template-collection.js'; import {yomitan} from '../../yomitan.js'; +/** + * @augments EventDispatcher<import('settings-controller').EventType> + */ export class SettingsController extends EventDispatcher { constructor() { super(); + /** @type {number} */ this._profileIndex = 0; + /** @type {string} */ this._source = generateId(16); + /** @type {Set<import('settings-controller').PageExitPrevention>} */ this._pageExitPreventions = new Set(); + /** @type {EventListenerCollection} */ this._pageExitPreventionEventListeners = new EventListenerCollection(); - this._templates = new HtmlTemplateCollection(document); + /** @type {HtmlTemplateCollection} */ + this._templates = new HtmlTemplateCollection(); + this._templates.load(document); + /** @type {PermissionsUtil} */ this._permissionsUtil = new PermissionsUtil(); } + /** @type {string} */ get source() { return this._source; } + /** @type {number} */ get profileIndex() { return this._profileIndex; } @@ -46,10 +58,12 @@ export class SettingsController extends EventDispatcher { this._setProfileIndex(value, true); } + /** @type {PermissionsUtil} */ get permissionsUtil() { return this._permissionsUtil; } + /** */ async prepare() { yomitan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); if (this._canObservePermissionsChanges()) { @@ -63,67 +77,121 @@ export class SettingsController extends EventDispatcher { } } + /** */ async refresh() { await this._onOptionsUpdatedInternal(true); } + /** + * @returns {Promise<import('settings').ProfileOptions>} + */ async getOptions() { const optionsContext = this.getOptionsContext(); return await yomitan.api.optionsGet(optionsContext); } + /** + * @returns {Promise<import('settings').Options>} + */ async getOptionsFull() { return await yomitan.api.optionsGetFull(); } + /** + * @param {import('settings').Options} value + */ async setAllSettings(value) { const profileIndex = value.profileCurrent; await yomitan.api.setAllSettings(value, this._source); this._setProfileIndex(profileIndex, true); } + /** + * @param {import('settings-modifications').ScopedRead[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async getSettings(targets) { - return await this._getSettings(targets, {}); + return await this._getSettings(targets, null); } + /** + * @param {import('settings-modifications').Read[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async getGlobalSettings(targets) { - return await this._getSettings(targets, {scope: 'global'}); + return await this._getSettings(targets, {scope: 'global', optionsContext: null}); } + /** + * @param {import('settings-modifications').Read[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async getProfileSettings(targets) { - return await this._getSettings(targets, {scope: 'profile'}); + return await this._getSettings(targets, {scope: 'profile', optionsContext: null}); } + /** + * @param {import('settings-modifications').ScopedModification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async modifySettings(targets) { - return await this._modifySettings(targets, {}); + return await this._modifySettings(targets, null); } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async modifyGlobalSettings(targets) { - return await this._modifySettings(targets, {scope: 'global'}); + return await this._modifySettings(targets, {scope: 'global', optionsContext: null}); } + /** + * @param {import('settings-modifications').Modification[]} targets + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async modifyProfileSettings(targets) { - return await this._modifySettings(targets, {scope: 'profile'}); + return await this._modifySettings(targets, {scope: 'profile', optionsContext: null}); } + /** + * @param {string} path + * @param {unknown} value + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async setGlobalSetting(path, value) { return await this.modifyGlobalSettings([{action: 'set', path, value}]); } + /** + * @param {string} path + * @param {unknown} value + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async setProfileSetting(path, value) { return await this.modifyProfileSettings([{action: 'set', path, value}]); } + /** + * @returns {Promise<import('dictionary-importer').Summary[]>} + */ async getDictionaryInfo() { return await yomitan.api.getDictionaryInfo(); } + /** + * @returns {import('settings').OptionsContext} + */ getOptionsContext() { return {index: this._profileIndex}; } + /** + * @returns {import('settings-controller').PageExitPrevention} + */ preventPageExit() { - const obj = {end: null}; + /** @type {import('settings-controller').PageExitPrevention} */ + const obj = {}; obj.end = this._endPreventPageExit.bind(this, obj); if (this._pageExitPreventionEventListeners.size === 0) { this._pageExitPreventionEventListeners.addEventListener(window, 'beforeunload', this._onBeforeUnload.bind(this), false); @@ -132,14 +200,25 @@ export class SettingsController extends EventDispatcher { return obj; } + /** + * @param {string} name + * @returns {Element} + */ instantiateTemplate(name) { return this._templates.instantiate(name); } + /** + * @param {string} name + * @returns {DocumentFragment} + */ instantiateTemplateFragment(name) { return this._templates.instantiateFragment(name); } + /** + * @returns {Promise<import('settings').Options>} + */ async getDefaultOptions() { const optionsUtil = new OptionsUtil(); await optionsUtil.prepare(); @@ -149,22 +228,34 @@ export class SettingsController extends EventDispatcher { // Private + /** + * @param {number} value + * @param {boolean} canUpdateProfileIndex + */ _setProfileIndex(value, canUpdateProfileIndex) { this._profileIndex = value; this.trigger('optionsContextChanged'); this._onOptionsUpdatedInternal(canUpdateProfileIndex); } + /** + * @param {{source: string}} details + */ _onOptionsUpdated({source}) { if (source === this._source) { return; } this._onOptionsUpdatedInternal(true); } + /** + * @param {boolean} canUpdateProfileIndex + */ async _onOptionsUpdatedInternal(canUpdateProfileIndex) { const optionsContext = this.getOptionsContext(); try { const options = await this.getOptions(); - this.trigger('optionsChanged', {options, optionsContext}); + /** @type {import('settings-controller').OptionsChangedEvent} */ + const event = {options, optionsContext}; + this.trigger('optionsChanged', event); } catch (e) { if (canUpdateProfileIndex) { this._setProfileIndex(0, false); @@ -174,26 +265,49 @@ export class SettingsController extends EventDispatcher { } } - _setupTargets(targets, extraFields) { - return targets.map((target) => { - target = Object.assign({}, extraFields, target); - if (target.scope === 'profile') { - target.optionsContext = this.getOptionsContext(); - } - return target; - }); + /** + * @param {import('settings-modifications').OptionsScope} target + */ + _modifyOptionsScope(target) { + if (target.scope === 'profile') { + target.optionsContext = this.getOptionsContext(); + } } + /** + * @template {boolean} THasScope + * @param {import('settings-controller').SettingsRead<THasScope>[]} targets + * @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async _getSettings(targets, extraFields) { - targets = this._setupTargets(targets, extraFields); - return await yomitan.api.getSettings(targets); + const targets2 = targets.map((target) => { + const target2 = /** @type {import('settings-controller').SettingsRead<true>} */ (Object.assign({}, extraFields, target)); + this._modifyOptionsScope(target2); + return target2; + }); + return await yomitan.api.getSettings(targets2); } + /** + * @template {boolean} THasScope + * @param {import('settings-controller').SettingsModification<THasScope>[]} targets + * @param {import('settings-controller').SettingsExtraFields<THasScope>} extraFields + * @returns {Promise<import('settings-controller').ModifyResult[]>} + */ async _modifySettings(targets, extraFields) { - targets = this._setupTargets(targets, extraFields); - return await yomitan.api.modifySettings(targets, this._source); + const targets2 = targets.map((target) => { + const target2 = /** @type {import('settings-controller').SettingsModification<true>} */ (Object.assign({}, extraFields, target)); + this._modifyOptionsScope(target2); + return target2; + }); + return await yomitan.api.modifySettings(targets2, this._source); } + /** + * @param {BeforeUnloadEvent} e + * @returns {string|undefined} + */ _onBeforeUnload(e) { if (this._pageExitPreventions.size === 0) { return; @@ -204,6 +318,9 @@ export class SettingsController extends EventDispatcher { return ''; } + /** + * @param {import('settings-controller').PageExitPrevention} obj + */ _endPreventPageExit(obj) { this._pageExitPreventions.delete(obj); if (this._pageExitPreventions.size === 0) { @@ -211,18 +328,25 @@ export class SettingsController extends EventDispatcher { } } + /** */ _onPermissionsChanged() { this._triggerPermissionsChanged(); } + /** */ async _triggerPermissionsChanged() { - const event = 'permissionsChanged'; - if (!this.hasListeners(event)) { return; } + const eventName = 'permissionsChanged'; + if (!this.hasListeners(eventName)) { return; } const permissions = await this._permissionsUtil.getAllPermissions(); - this.trigger(event, {permissions}); + /** @type {import('settings-controller').PermissionsChangedEvent} */ + const event = {permissions}; + this.trigger(eventName, event); } + /** + * @returns {boolean} + */ _canObservePermissionsChanges() { return isObject(chrome.permissions) && isObject(chrome.permissions.onAdded) && isObject(chrome.permissions.onRemoved); } diff --git a/ext/js/pages/settings/settings-display-controller.js b/ext/js/pages/settings/settings-display-controller.js index e23e355d..16e6cfae 100644 --- a/ext/js/pages/settings/settings-display-controller.js +++ b/ext/js/pages/settings/settings-display-controller.js @@ -21,44 +21,54 @@ import {PopupMenu} from '../../dom/popup-menu.js'; import {SelectorObserver} from '../../dom/selector-observer.js'; export class SettingsDisplayController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + * @param {import('./modal-controller.js').ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {import('./modal-controller.js').ModalController} */ this._modalController = modalController; + /** @type {?HTMLElement} */ this._contentNode = null; + /** @type {?HTMLElement} */ this._menuContainer = null; - this._onMoreToggleClickBind = null; - this._onMenuButtonClickBind = null; + /** @type {(event: MouseEvent) => void} */ + this._onMoreToggleClickBind = this._onMoreToggleClick.bind(this); + /** @type {(event: MouseEvent) => void} */ + this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this); } + /** */ prepare() { - this._contentNode = document.querySelector('.content'); - this._menuContainer = document.querySelector('#popup-menus'); + this._contentNode = /** @type {HTMLElement} */ (document.querySelector('.content')); + this._menuContainer = /** @type {HTMLElement} */ (document.querySelector('#popup-menus')); const onFabButtonClick = this._onFabButtonClick.bind(this); - for (const fabButton of document.querySelectorAll('.fab-button')) { + for (const fabButton of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.fab-button'))) { fabButton.addEventListener('click', onFabButtonClick, false); } const onModalAction = this._onModalAction.bind(this); - for (const node of document.querySelectorAll('[data-modal-action]')) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-modal-action]'))) { node.addEventListener('click', onModalAction, false); } const onSelectOnClickElementClick = this._onSelectOnClickElementClick.bind(this); - for (const node of document.querySelectorAll('[data-select-on-click]')) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-select-on-click]'))) { node.addEventListener('click', onSelectOnClickElementClick, false); } const onInputTabActionKeyDown = this._onInputTabActionKeyDown.bind(this); - for (const node of document.querySelectorAll('[data-tab-action]')) { + for (const node of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('[data-tab-action]'))) { node.addEventListener('keydown', onInputTabActionKeyDown, false); } - for (const node of document.querySelectorAll('.defer-load-iframe')) { + for (const node of /** @type {NodeListOf<HTMLIFrameElement>} */ (document.querySelectorAll('.defer-load-iframe'))) { this._setupDeferLoadIframe(node); } - this._onMoreToggleClickBind = this._onMoreToggleClick.bind(this); const moreSelectorObserver = new SelectorObserver({ selector: '.more-toggle', onAdded: this._onMoreSetup.bind(this), @@ -66,7 +76,6 @@ export class SettingsDisplayController { }); moreSelectorObserver.observe(document.documentElement, false); - this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this); const menuSelectorObserver = new SelectorObserver({ selector: '[data-menu]', onAdded: this._onMenuSetup.bind(this), @@ -81,32 +90,54 @@ export class SettingsDisplayController { // Private + /** + * @param {Element} element + * @returns {null} + */ _onMoreSetup(element) { - element.addEventListener('click', this._onMoreToggleClickBind, false); + /** @type {HTMLElement} */ (element).addEventListener('click', this._onMoreToggleClickBind, false); return null; } + /** + * @param {Element} element + */ _onMoreCleanup(element) { - element.removeEventListener('click', this._onMoreToggleClickBind, false); + /** @type {HTMLElement} */ (element).removeEventListener('click', this._onMoreToggleClickBind, false); } + /** + * @param {Element} element + * @returns {null} + */ _onMenuSetup(element) { - element.addEventListener('click', this._onMenuButtonClickBind, false); + /** @type {HTMLElement} */ (element).addEventListener('click', this._onMenuButtonClickBind, false); return null; } + /** + * @param {Element} element + */ _onMenuCleanup(element) { - element.removeEventListener('click', this._onMenuButtonClickBind, false); + /** @type {HTMLElement} */ (element).removeEventListener('click', this._onMenuButtonClickBind, false); } + /** + * @param {MouseEvent} e + */ _onMenuButtonClick(e) { - const element = e.currentTarget; + const element = /** @type {HTMLElement} */ (e.currentTarget); const {menu} = element.dataset; + if (typeof menu === 'undefined') { return; } this._showMenu(element, menu); } + /** + * @param {MouseEvent} e + */ _onFabButtonClick(e) { - const action = e.currentTarget.dataset.action; + const element = /** @type {HTMLElement} */ (e.currentTarget); + const action = element.dataset.action; switch (action) { case 'toggle-sidebar': document.body.classList.toggle('sidebar-visible'); @@ -117,16 +148,20 @@ export class SettingsDisplayController { } } + /** + * @param {MouseEvent} e + */ _onMoreToggleClick(e) { - const container = this._getMoreContainer(e.currentTarget); + const node = /** @type {HTMLElement} */ (e.currentTarget); + const container = this._getMoreContainer(node); if (container === null) { return; } - const more = container.querySelector('.more'); + const more = /** @type {?HTMLElement} */ (container.querySelector('.more')); if (more === null) { return; } const moreVisible = more.hidden; more.hidden = !moreVisible; - for (const moreToggle of container.querySelectorAll('.more-toggle')) { + for (const moreToggle of /** @type {NodeListOf<HTMLElement>} */ (container.querySelectorAll('.more-toggle'))) { const container2 = this._getMoreContainer(moreToggle); if (container2 === null) { continue; } @@ -137,13 +172,16 @@ export class SettingsDisplayController { } e.preventDefault(); - return false; } + /** */ _onPopState() { this._updateScrollTarget(); } + /** + * @param {KeyboardEvent} e + */ _onKeyDown(e) { switch (e.code) { case 'Escape': @@ -155,12 +193,18 @@ export class SettingsDisplayController { } } + /** + * @param {MouseEvent} e + */ _onModalAction(e) { - const node = e.currentTarget; + const node = /** @type {HTMLElement} */ (e.currentTarget); const {modalAction} = node.dataset; if (typeof modalAction !== 'string') { return; } - let [action, target] = modalAction.split(','); + const modalActionArray = modalAction.split(','); + const action = modalActionArray[0]; + /** @type {string|Element|undefined} */ + let target = modalActionArray[1]; if (typeof target === 'undefined') { const currentModal = node.closest('.modal'); if (currentModal === null) { return; } @@ -185,26 +229,33 @@ export class SettingsDisplayController { e.preventDefault(); } + /** + * @param {MouseEvent} e + */ _onSelectOnClickElementClick(e) { if (e.button !== 0) { return; } - const node = e.currentTarget; + const node = /** @type {HTMLElement} */ (e.currentTarget); const range = document.createRange(); range.selectNode(node); const selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); + if (selection !== null) { + selection.removeAllRanges(); + selection.addRange(range); + } e.preventDefault(); e.stopPropagation(); - return false; } + /** + * @param {KeyboardEvent} e + */ _onInputTabActionKeyDown(e) { if (e.key !== 'Tab' || e.ctrlKey) { return; } - const node = e.currentTarget; + const node = /** @type {HTMLElement} */ (e.currentTarget); const {tabAction} = node.dataset; if (typeof tabAction !== 'string') { return; } @@ -220,6 +271,7 @@ export class SettingsDisplayController { } } + /** */ _updateScrollTarget() { const hash = window.location.hash; if (!hash.startsWith('#!')) { return; } @@ -233,18 +285,25 @@ export class SettingsDisplayController { content.scrollTop += rect2.top - rect1.top; } + /** + * @param {HTMLElement} link + * @returns {?Element} + */ _getMoreContainer(link) { const v = link.dataset.parentDistance; const distance = v ? parseInt(v, 10) : 1; if (Number.isNaN(distance)) { return null; } + /** @type {?Element} */ + let result = link; for (let i = 0; i < distance; ++i) { - link = link.parentNode; - if (link === null) { break; } + if (result === null) { break; } + result = /** @type {?Element} */ (result.parentNode); } - return link; + return result; } + /** */ _closeTopMenuOrModal() { for (const popupMenu of PopupMenu.openMenus) { popupMenu.close(); @@ -257,17 +316,27 @@ export class SettingsDisplayController { } } + /** + * @param {HTMLElement} element + * @param {string} menuName + */ _showMenu(element, menuName) { - const menu = this._settingsController.instantiateTemplate(menuName); - if (menu === null) { return; } + const menu = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate(menuName)); - this._menuContainer.appendChild(menu); + /** @type {HTMLElement} */ (this._menuContainer).appendChild(menu); const popupMenu = new PopupMenu(element, menu); popupMenu.prepare(); } + /** + * @param {KeyboardEvent} e + * @param {HTMLElement} node + * @param {string[]} args + */ _indentInput(e, node, args) { + if (!(node instanceof HTMLTextAreaElement)) { return; } + let indent = '\t'; if (args.length > 1) { const count = parseInt(args[1], 10); @@ -276,7 +345,8 @@ export class SettingsDisplayController { const {selectionStart: start, selectionEnd: end, value} = node; const lineStart = value.substring(0, start).lastIndexOf('\n') + 1; - const lineWhitespace = /^[ \t]*/.exec(value.substring(lineStart))[0]; + const lineWhitespaceMatch = /^[ \t]*/.exec(value.substring(lineStart)); + const lineWhitespace = lineWhitespaceMatch !== null ? lineWhitespaceMatch[0] : ''; if (e.shiftKey) { const whitespaceLength = Math.max(0, Math.floor((lineWhitespace.length - 1) / 4) * 4); @@ -298,17 +368,23 @@ export class SettingsDisplayController { } } + /** + * @param {HTMLIFrameElement} element + */ _setupDeferLoadIframe(element) { const parent = this._getMoreContainer(element); if (parent === null) { return; } + /** @type {?MutationObserver} */ let mutationObserver = null; const callback = () => { if (!this._isElementVisible(element)) { return false; } const src = element.dataset.src; delete element.dataset.src; - element.src = src; + if (typeof src === 'string') { + element.src = src; + } if (mutationObserver === null) { return true; } @@ -323,6 +399,10 @@ export class SettingsDisplayController { mutationObserver.observe(parent, {attributes: true}); } + /** + * @param {HTMLElement} element + * @returns {boolean} + */ _isElementVisible(element) { return (element.offsetParent !== null); } diff --git a/ext/js/pages/settings/settings-main.js b/ext/js/pages/settings/settings-main.js index f9ea0aa1..1b9723e8 100644 --- a/ext/js/pages/settings/settings-main.js +++ b/ext/js/pages/settings/settings-main.js @@ -49,6 +49,9 @@ import {StatusFooter} from './status-footer.js'; import {StorageController} from './storage-controller.js'; import {TranslationTextReplacementsController} from './translation-text-replacements-controller.js'; +/** + * @param {GenericSettingController} genericSettingController + */ async function setupGenericSettingsController(genericSettingController) { await genericSettingController.prepare(); await genericSettingController.refresh(); @@ -62,10 +65,11 @@ async function setupGenericSettingsController(genericSettingController) { const extensionContentController = new ExtensionContentController(); extensionContentController.prepare(); - const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); + const statusFooter = new StatusFooter(/** @type {HTMLElement} */ (document.querySelector('.status-footer-container'))); statusFooter.prepare(); - let prepareTimer = setTimeout(() => { + /** @type {?number} */ + let prepareTimer = window.setTimeout(() => { prepareTimer = null; document.documentElement.dataset.loadingStalled = 'true'; }, 1000); diff --git a/ext/js/pages/settings/sort-frequency-dictionary-controller.js b/ext/js/pages/settings/sort-frequency-dictionary-controller.js index 53104085..e7759d95 100644 --- a/ext/js/pages/settings/sort-frequency-dictionary-controller.js +++ b/ext/js/pages/settings/sort-frequency-dictionary-controller.js @@ -19,20 +19,30 @@ import {yomitan} from '../../yomitan.js'; export class SortFrequencyDictionaryController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?HTMLSelectElement} */ this._sortFrequencyDictionarySelect = null; + /** @type {?HTMLSelectElement} */ this._sortFrequencyDictionaryOrderSelect = null; + /** @type {?HTMLButtonElement} */ this._sortFrequencyDictionaryOrderAutoButton = null; + /** @type {?HTMLElement} */ this._sortFrequencyDictionaryOrderContainerNode = null; + /** @type {?import('core').TokenObject} */ this._getDictionaryInfoToken = null; } + /** */ async prepare() { - this._sortFrequencyDictionarySelect = document.querySelector('#sort-frequency-dictionary'); - this._sortFrequencyDictionaryOrderSelect = document.querySelector('#sort-frequency-dictionary-order'); - this._sortFrequencyDictionaryOrderAutoButton = document.querySelector('#sort-frequency-dictionary-order-auto'); - this._sortFrequencyDictionaryOrderContainerNode = document.querySelector('#sort-frequency-dictionary-order-container'); + this._sortFrequencyDictionarySelect = /** @type {HTMLSelectElement} */ (document.querySelector('#sort-frequency-dictionary')); + this._sortFrequencyDictionaryOrderSelect = /** @type {HTMLSelectElement} */ (document.querySelector('#sort-frequency-dictionary-order')); + this._sortFrequencyDictionaryOrderAutoButton = /** @type {HTMLButtonElement} */ (document.querySelector('#sort-frequency-dictionary-order-auto')); + this._sortFrequencyDictionaryOrderContainerNode = /** @type {HTMLElement} */ (document.querySelector('#sort-frequency-dictionary-order-container')); await this._onDatabaseUpdated(); @@ -45,7 +55,9 @@ export class SortFrequencyDictionaryController { // Private + /** */ async _onDatabaseUpdated() { + /** @type {?import('core').TokenObject} */ const token = {}; this._getDictionaryInfoToken = token; const dictionaries = await this._settingsController.getDictionaryInfo(); @@ -55,33 +67,44 @@ export class SortFrequencyDictionaryController { this._updateDictionaryOptions(dictionaries); const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { const {sortFrequencyDictionary, sortFrequencyDictionaryOrder} = options.general; - this._sortFrequencyDictionarySelect.value = (sortFrequencyDictionary !== null ? sortFrequencyDictionary : ''); - this._sortFrequencyDictionaryOrderSelect.value = sortFrequencyDictionaryOrder; - this._sortFrequencyDictionaryOrderContainerNode.hidden = (sortFrequencyDictionary === null); + /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect).value = (sortFrequencyDictionary !== null ? sortFrequencyDictionary : ''); + /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = sortFrequencyDictionaryOrder; + /** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (sortFrequencyDictionary === null); } + /** */ _onSortFrequencyDictionarySelectChange() { - let {value} = this._sortFrequencyDictionarySelect; - if (value === '') { value = null; } - this._setSortFrequencyDictionaryValue(value); + const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect); + this._setSortFrequencyDictionaryValue(value !== '' ? value : null); } + /** */ _onSortFrequencyDictionaryOrderSelectChange() { - const {value} = this._sortFrequencyDictionaryOrderSelect; - this._setSortFrequencyDictionaryOrderValue(value); + const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect); + const value2 = this._normalizeSortFrequencyDictionaryOrder(value); + if (value2 === null) { return; } + this._setSortFrequencyDictionaryOrderValue(value2); } + /** */ _onSortFrequencyDictionaryOrderAutoButtonClick() { - const {value} = this._sortFrequencyDictionarySelect; + const {value} = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect); if (value === '') { return; } this._autoUpdateOrder(value); } + /** + * @param {import('dictionary-importer').Summary[]} dictionaries + */ _updateDictionaryOptions(dictionaries) { const fragment = document.createDocumentFragment(); let option = document.createElement('option'); @@ -95,30 +118,44 @@ export class SortFrequencyDictionaryController { option.textContent = title; fragment.appendChild(option); } - this._sortFrequencyDictionarySelect.textContent = ''; - this._sortFrequencyDictionarySelect.appendChild(fragment); + const select = /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionarySelect); + select.textContent = ''; + select.appendChild(fragment); } + /** + * @param {?string} value + */ async _setSortFrequencyDictionaryValue(value) { - this._sortFrequencyDictionaryOrderContainerNode.hidden = (value === null); + /** @type {HTMLElement} */ (this._sortFrequencyDictionaryOrderContainerNode).hidden = (value === null); await this._settingsController.setProfileSetting('general.sortFrequencyDictionary', value); if (value !== null) { await this._autoUpdateOrder(value); } } + /** + * @param {import('settings').SortFrequencyDictionaryOrder} value + */ async _setSortFrequencyDictionaryOrderValue(value) { await this._settingsController.setProfileSetting('general.sortFrequencyDictionaryOrder', value); } + /** + * @param {string} dictionary + */ async _autoUpdateOrder(dictionary) { const order = await this._getFrequencyOrder(dictionary); if (order === 0) { return; } const value = (order > 0 ? 'descending' : 'ascending'); - this._sortFrequencyDictionaryOrderSelect.value = value; + /** @type {HTMLSelectElement} */ (this._sortFrequencyDictionaryOrderSelect).value = value; await this._setSortFrequencyDictionaryOrderValue(value); } + /** + * @param {string} dictionary + * @returns {Promise<number>} + */ async _getFrequencyOrder(dictionary) { const moreCommonTerms = ['来る', '言う', '出る', '入る', '方', '男', '女', '今', '何', '時']; const lessCommonTerms = ['行なう', '論じる', '過す', '行方', '人口', '猫', '犬', '滝', '理', '暁']; @@ -129,6 +166,7 @@ export class SortFrequencyDictionaryController { [dictionary] ); + /** @type {Map<string, {hasValue: boolean, minValue: number, maxValue: number}>} */ const termDetails = new Map(); const moreCommonTermDetails = []; const lessCommonTermDetails = []; @@ -144,7 +182,6 @@ export class SortFrequencyDictionaryController { } for (const {term, frequency} of frequencies) { - if (typeof frequency !== 'number') { continue; } const details = termDetails.get(term); if (typeof details === 'undefined') { continue; } details.minValue = Math.min(details.minValue, frequency); @@ -163,10 +200,28 @@ export class SortFrequencyDictionaryController { return Math.sign(result); } + /** + * @param {import('dictionary-importer').SummaryCounts} counts + * @returns {boolean} + */ _dictionaryHasNoFrequencies(counts) { if (typeof counts !== 'object' || counts === null) { return false; } const {termMeta} = counts; if (typeof termMeta !== 'object' || termMeta === null) { return false; } return termMeta.freq <= 0; } + + /** + * @param {string} value + * @returns {?import('settings').SortFrequencyDictionaryOrder} + */ + _normalizeSortFrequencyDictionaryOrder(value) { + switch (value) { + case 'ascending': + case 'descending': + return value; + default: + return null; + } + } } diff --git a/ext/js/pages/settings/status-footer.js b/ext/js/pages/settings/status-footer.js index 6c64794f..a8f1a8c4 100644 --- a/ext/js/pages/settings/status-footer.js +++ b/ext/js/pages/settings/status-footer.js @@ -19,34 +19,52 @@ import {PanelElement} from '../../dom/panel-element.js'; export class StatusFooter extends PanelElement { + /** + * @param {HTMLElement} node + */ constructor(node) { super({ node, closingAnimationDuration: 375 // Milliseconds; includes buffer }); - this._body = node.querySelector('.status-footer'); + /** @type {HTMLElement} */ + this._body = /** @type {HTMLElement} */ (node.querySelector('.status-footer')); } + /** */ prepare() { - this.on('closeCompleted', this._onCloseCompleted.bind(this), false); - this._body.querySelector('.status-footer-header-close').addEventListener('click', this._onCloseClick.bind(this), false); + const closeButton = /** @type {HTMLElement} */ (this._body.querySelector('.status-footer-header-close')); + this.on('closeCompleted', this._onCloseCompleted.bind(this)); + closeButton.addEventListener('click', this._onCloseClick.bind(this), false); } + /** + * @param {string} selector + * @returns {?HTMLElement} + */ getTaskContainer(selector) { return this._body.querySelector(selector); } + /** + * @param {string} selector + * @returns {boolean} + */ isTaskActive(selector) { const target = this.getTaskContainer(selector); - return (target !== null && target.dataset.active); + return (target !== null && !!target.dataset.active); } + /** + * @param {string} selector + * @param {boolean} active + */ setTaskActive(selector, active) { const target = this.getTaskContainer(selector); if (target === null) { return; } const activeElements = new Set(); - for (const element of this._body.querySelectorAll('.status-footer-item')) { + for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) { if (element.dataset.active) { activeElements.add(element); } @@ -68,13 +86,17 @@ export class StatusFooter extends PanelElement { // Private + /** + * @param {MouseEvent} e + */ _onCloseClick(e) { e.preventDefault(); this.setVisible(false); } + /** */ _onCloseCompleted() { - for (const element of this._body.querySelectorAll('.status-footer-item')) { + for (const element of /** @type {NodeListOf<HTMLElement>} */ (this._body.querySelectorAll('.status-footer-item'))) { if (!element.dataset.active) { element.hidden = true; } diff --git a/ext/js/pages/settings/storage-controller.js b/ext/js/pages/settings/storage-controller.js index ba1145b8..7f323b48 100644 --- a/ext/js/pages/settings/storage-controller.js +++ b/ext/js/pages/settings/storage-controller.js @@ -19,28 +19,43 @@ import {yomitan} from '../../yomitan.js'; export class StorageController { + /** + * @param {import('./persistent-storage-controller.js').PersistentStorageController} persistentStorageController + */ constructor(persistentStorageController) { + /** @type {import('./persistent-storage-controller.js').PersistentStorageController} */ this._persistentStorageController = persistentStorageController; + /** @type {?StorageEstimate} */ this._mostRecentStorageEstimate = null; + /** @type {boolean} */ this._storageEstimateFailed = false; + /** @type {boolean} */ this._isUpdating = false; - this._storageUsageNode = null; - this._storageQuotaNode = null; + /** @type {?NodeListOf<HTMLElement>} */ + this._storageUsageNodes = null; + /** @type {?NodeListOf<HTMLElement>} */ + this._storageQuotaNodes = null; + /** @type {?NodeListOf<HTMLElement>} */ this._storageUseFiniteNodes = null; + /** @type {?NodeListOf<HTMLElement>} */ this._storageUseInfiniteNodes = null; + /** @type {?NodeListOf<HTMLElement>} */ this._storageUseValidNodes = null; + /** @type {?NodeListOf<HTMLElement>} */ this._storageUseInvalidNodes = null; } + /** */ prepare() { - this._storageUsageNodes = document.querySelectorAll('.storage-usage'); - this._storageQuotaNodes = document.querySelectorAll('.storage-quota'); - this._storageUseFiniteNodes = document.querySelectorAll('.storage-use-finite'); - this._storageUseInfiniteNodes = document.querySelectorAll('.storage-use-infinite'); - this._storageUseValidNodes = document.querySelectorAll('.storage-use-valid'); - this._storageUseInvalidNodes = document.querySelectorAll('.storage-use-invalid'); - - document.querySelector('#storage-refresh').addEventListener('click', this._onStorageRefreshButtonClick.bind(this), false); + this._storageUsageNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-usage')); + this._storageQuotaNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-quota')); + this._storageUseFiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-finite')); + this._storageUseInfiniteNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-infinite')); + this._storageUseValidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-valid')); + this._storageUseInvalidNodes = /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll('.storage-use-invalid')); + const storageRefreshButton = /** @type {HTMLButtonElement} */ (document.querySelector('#storage-refresh')); + + storageRefreshButton.addEventListener('click', this._onStorageRefreshButtonClick.bind(this), false); yomitan.on('storageChanged', this._onStorageChanged.bind(this)); this._updateStats(); @@ -48,14 +63,17 @@ export class StorageController { // Private + /** */ _onStorageRefreshButtonClick() { this._updateStats(); } + /** */ _onStorageChanged() { this._updateStats(); } + /** */ async _updateStats() { if (this._isUpdating) { return; } @@ -66,13 +84,18 @@ export class StorageController { const valid = (estimate !== null); // Firefox reports usage as 0 when persistent storage is enabled. - const finite = valid && (estimate.usage > 0 || !(await this._persistentStorageController.isStoragePeristent())); + const finite = valid && ((typeof estimate.usage === 'number' && estimate.usage > 0) || !(await this._persistentStorageController.isStoragePeristent())); if (finite) { - for (const node of this._storageUsageNodes) { - node.textContent = this._bytesToLabeledString(estimate.usage); + let {usage, quota} = estimate; + if (typeof usage !== 'number') { usage = 0; } + if (typeof quota !== 'number') { quota = 0; } + const usageString = this._bytesToLabeledString(usage); + const quotaString = this._bytesToLabeledString(quota); + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageUsageNodes)) { + node.textContent = usageString; } - for (const node of this._storageQuotaNodes) { - node.textContent = this._bytesToLabeledString(estimate.quota); + for (const node of /** @type {NodeListOf<HTMLElement>} */ (this._storageQuotaNodes)) { + node.textContent = quotaString; } } @@ -80,8 +103,6 @@ export class StorageController { this._setElementsVisible(this._storageUseInfiniteNodes, valid && !finite); this._setElementsVisible(this._storageUseValidNodes, valid); this._setElementsVisible(this._storageUseInvalidNodes, !valid); - - return valid; } finally { this._isUpdating = false; } @@ -89,6 +110,9 @@ export class StorageController { // Private + /** + * @returns {Promise<?StorageEstimate>} + */ async _storageEstimate() { if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) { return null; @@ -103,6 +127,10 @@ export class StorageController { return null; } + /** + * @param {number} size + * @returns {string} + */ _bytesToLabeledString(size) { const base = 1000; const labels = [' bytes', 'KB', 'MB', 'GB', 'TB']; @@ -116,7 +144,12 @@ export class StorageController { return `${label}${labels[labelIndex]}`; } + /** + * @param {?NodeListOf<HTMLElement>} elements + * @param {boolean} visible + */ _setElementsVisible(elements, visible) { + if (elements === null) { return; } visible = !visible; for (const element of elements) { element.hidden = visible; diff --git a/ext/js/pages/settings/translation-text-replacements-controller.js b/ext/js/pages/settings/translation-text-replacements-controller.js index 4a860b52..050db8d1 100644 --- a/ext/js/pages/settings/translation-text-replacements-controller.js +++ b/ext/js/pages/settings/translation-text-replacements-controller.js @@ -19,15 +19,22 @@ import {EventListenerCollection} from '../../core.js'; export class TranslationTextReplacementsController { + /** + * @param {import('./settings-controller.js').SettingsController} settingsController + */ constructor(settingsController) { + /** @type {import('./settings-controller.js').SettingsController} */ this._settingsController = settingsController; + /** @type {?HTMLElement} */ this._entryContainer = null; + /** @type {TranslationTextReplacementsEntry[]} */ this._entries = []; } + /** */ async prepare() { - this._entryContainer = document.querySelector('#translation-text-replacement-list'); - const addButton = document.querySelector('#translation-text-replacement-add'); + this._entryContainer = /** @type {HTMLElement} */ (document.querySelector('#translation-text-replacement-list')); + const addButton = /** @type {HTMLButtonElement} */ (document.querySelector('#translation-text-replacement-add')); addButton.addEventListener('click', this._onAdd.bind(this), false); this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); @@ -35,11 +42,12 @@ export class TranslationTextReplacementsController { await this._updateOptions(); } - + /** */ async addGroup() { const options = await this._settingsController.getOptions(); const {groups} = options.translation.textReplacements; const newEntry = this._createNewEntry(); + /** @type {import('settings-modifications').Modification} */ const target = ( (groups.length === 0) ? { @@ -62,6 +70,10 @@ export class TranslationTextReplacementsController { await this._updateOptions(); } + /** + * @param {number} index + * @returns {Promise<boolean>} + */ async deleteGroup(index) { const options = await this._settingsController.getOptions(); const {groups} = options.translation.textReplacements; @@ -70,6 +82,7 @@ export class TranslationTextReplacementsController { const group0 = groups[0]; if (index < 0 || index >= group0.length) { return false; } + /** @type {import('settings-modifications').Modification} */ const target = ( (group0.length > 1) ? { @@ -95,6 +108,9 @@ export class TranslationTextReplacementsController { // Private + /** + * @param {import('settings-controller').OptionsChangedEvent} details + */ _onOptionsChanged({options}) { for (const entry of this._entries) { entry.cleanup(); @@ -105,50 +121,70 @@ export class TranslationTextReplacementsController { if (groups.length > 0) { const group0 = groups[0]; for (let i = 0, ii = group0.length; i < ii; ++i) { - const data = group0[i]; - const node = this._settingsController.instantiateTemplate('translation-text-replacement-entry'); - this._entryContainer.appendChild(node); - const entry = new TranslationTextReplacementsEntry(this, node, i, data); + const node = /** @type {HTMLElement} */ (this._settingsController.instantiateTemplate('translation-text-replacement-entry')); + /** @type {HTMLElement} */ (this._entryContainer).appendChild(node); + const entry = new TranslationTextReplacementsEntry(this, node, i); this._entries.push(entry); entry.prepare(); } } } + /** */ _onAdd() { this.addGroup(); } + /** */ async _updateOptions() { const options = await this._settingsController.getOptions(); - this._onOptionsChanged({options}); + const optionsContext = this._settingsController.getOptionsContext(); + this._onOptionsChanged({options, optionsContext}); } + /** + * @returns {import('settings').TranslationTextReplacementGroup} + */ _createNewEntry() { return {pattern: '', ignoreCase: false, replacement: ''}; } } class TranslationTextReplacementsEntry { + /** + * @param {TranslationTextReplacementsController} parent + * @param {HTMLElement} node + * @param {number} index + */ constructor(parent, node, index) { + /** @type {TranslationTextReplacementsController} */ this._parent = parent; + /** @type {HTMLElement} */ this._node = node; + /** @type {number} */ this._index = index; + /** @type {EventListenerCollection} */ this._eventListeners = new EventListenerCollection(); + /** @type {?HTMLInputElement} */ this._patternInput = null; + /** @type {?HTMLInputElement} */ this._replacementInput = null; + /** @type {?HTMLInputElement} */ this._ignoreCaseToggle = null; + /** @type {?HTMLInputElement} */ this._testInput = null; + /** @type {?HTMLInputElement} */ this._testOutput = null; } + /** */ prepare() { - const patternInput = this._node.querySelector('.translation-text-replacement-pattern'); - const replacementInput = this._node.querySelector('.translation-text-replacement-replacement'); - const ignoreCaseToggle = this._node.querySelector('.translation-text-replacement-pattern-ignore-case'); - const menuButton = this._node.querySelector('.translation-text-replacement-button'); - const testInput = this._node.querySelector('.translation-text-replacement-test-input'); - const testOutput = this._node.querySelector('.translation-text-replacement-test-output'); + const patternInput = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-pattern')); + const replacementInput = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-replacement')); + const ignoreCaseToggle = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-pattern-ignore-case')); + const menuButton = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-button')); + const testInput = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-test-input')); + const testOutput = /** @type {HTMLInputElement} */ (this._node.querySelector('.translation-text-replacement-test-output')); this._patternInput = patternInput; this._replacementInput = replacementInput; @@ -169,6 +205,7 @@ class TranslationTextReplacementsEntry { this._eventListeners.addEventListener(testInput, 'input', this._updateTestInput.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._node.parentNode !== null) { @@ -178,13 +215,19 @@ class TranslationTextReplacementsEntry { // Private + /** + * @param {import('popup-menu').MenuOpenEvent} e + */ _onMenuOpen(e) { const bodyNode = e.detail.menu.bodyNode; const testVisible = this._isTestVisible(); - bodyNode.querySelector('[data-menu-action=showTest]').hidden = testVisible; - bodyNode.querySelector('[data-menu-action=hideTest]').hidden = !testVisible; + /** @type {HTMLElement} */ (bodyNode.querySelector('[data-menu-action=showTest]')).hidden = testVisible; + /** @type {HTMLElement} */ (bodyNode.querySelector('[data-menu-action=hideTest]')).hidden = !testVisible; } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'remove': @@ -199,34 +242,58 @@ class TranslationTextReplacementsEntry { } } + /** + * @param {import('dom-data-binder').SettingChangedEvent} deatils + */ _onPatternChanged({detail: {value}}) { this._validatePattern(value); this._updateTestInput(); } + /** + * @param {unknown} value + */ _validatePattern(value) { let okay = false; try { - new RegExp(value, 'g'); - okay = true; + if (typeof value === 'string') { + new RegExp(value, 'g'); + okay = true; + } } catch (e) { // NOP } - this._patternInput.dataset.invalid = `${!okay}`; + if (this._patternInput !== null) { + this._patternInput.dataset.invalid = `${!okay}`; + } } + /** + * @returns {boolean} + */ _isTestVisible() { return this._node.dataset.testVisible === 'true'; } + /** + * @param {boolean} visible + */ _setTestVisible(visible) { this._node.dataset.testVisible = `${visible}`; this._updateTestInput(); } + /** */ _updateTestInput() { - if (!this._isTestVisible()) { return; } + if ( + !this._isTestVisible() || + this._ignoreCaseToggle === null || + this._patternInput === null || + this._replacementInput === null || + this._testInput === null || + this._testOutput === null + ) { return; } const ignoreCase = this._ignoreCaseToggle.checked; const pattern = this._patternInput.value; diff --git a/ext/js/pages/welcome-main.js b/ext/js/pages/welcome-main.js index b1438187..c034aae1 100644 --- a/ext/js/pages/welcome-main.js +++ b/ext/js/pages/welcome-main.js @@ -30,6 +30,7 @@ import {SettingsController} from './settings/settings-controller.js'; import {SettingsDisplayController} from './settings/settings-display-controller.js'; import {StatusFooter} from './settings/status-footer.js'; +/** */ async function setupEnvironmentInfo() { const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); const {browser, platform} = await yomitan.api.getEnvironmentInfo(); @@ -38,6 +39,9 @@ async function setupEnvironmentInfo() { document.documentElement.dataset.manifestVersion = `${manifestVersion}`; } +/** + * @param {GenericSettingController} genericSettingController + */ async function setupGenericSettingsController(genericSettingController) { await genericSettingController.prepare(); await genericSettingController.refresh(); @@ -51,7 +55,7 @@ async function setupGenericSettingsController(genericSettingController) { const extensionContentController = new ExtensionContentController(); extensionContentController.prepare(); - const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); + const statusFooter = new StatusFooter(/** @type {HTMLElement} */ (document.querySelector('.status-footer-container'))); statusFooter.prepare(); await yomitan.prepare(); diff --git a/ext/js/script/dynamic-loader-sentinel.js b/ext/js/script/dynamic-loader-sentinel.js index 3c06fc02..d77f3cb0 100644 --- a/ext/js/script/dynamic-loader-sentinel.js +++ b/ext/js/script/dynamic-loader-sentinel.js @@ -18,5 +18,4 @@ import {yomitan} from '../yomitan.js'; - -yomitan.trigger('dynamicLoaderSentinel', {script: import.meta.url}); +yomitan.trigger('dynamicLoaderSentinel', /** @type {import('dynamic-loader').DynamicLoaderSentinelDetails} */ ({scriptUrl: import.meta.url})); diff --git a/ext/js/script/dynamic-loader.js b/ext/js/script/dynamic-loader.js index 2abc9e04..75bf7b8b 100644 --- a/ext/js/script/dynamic-loader.js +++ b/ext/js/script/dynamic-loader.js @@ -19,9 +19,16 @@ import {yomitan} from '../yomitan.js'; export const dynamicLoader = (() => { + /** @type {Map<string, ?HTMLStyleElement|HTMLLinkElement>} */ const injectedStylesheets = new Map(); + /** @type {WeakMap<Node, Map<string, ?HTMLStyleElement|HTMLLinkElement>>} */ const injectedStylesheetsWithParent = new WeakMap(); + /** + * @param {string} id + * @param {?Node} parentNode + * @returns {?HTMLStyleElement|HTMLLinkElement|undefined} + */ function getInjectedStylesheet(id, parentNode) { if (parentNode === null) { return injectedStylesheets.get(id); @@ -30,6 +37,11 @@ export const dynamicLoader = (() => { return typeof map !== 'undefined' ? map.get(id) : void 0; } + /** + * @param {string} id + * @param {?Node} parentNode + * @param {?HTMLStyleElement|HTMLLinkElement} value + */ function setInjectedStylesheet(id, parentNode, value) { if (parentNode === null) { injectedStylesheets.set(id, value); @@ -43,6 +55,15 @@ export const dynamicLoader = (() => { map.set(id, value); } + /** + * @param {string} id + * @param {'code'|'file'|'file-content'} type + * @param {string} value + * @param {boolean} [useWebExtensionApi] + * @param {?Node} [parentNode] + * @returns {Promise<?HTMLStyleElement|HTMLLinkElement>} + * @throws {Error} + */ async function loadStyle(id, type, value, useWebExtensionApi=false, parentNode=null) { if (useWebExtensionApi && yomitan.isExtensionUrl(window.location.href)) { // Permissions error will occur if trying to use the WebExtension API to inject into an extension page @@ -97,8 +118,8 @@ export const dynamicLoader = (() => { // Update node style if (isFile) { - styleNode.rel = 'stylesheet'; - styleNode.href = value; + /** @type {HTMLLinkElement} */ (styleNode).rel = 'stylesheet'; + /** @type {HTMLLinkElement} */ (styleNode).href = value; } else { styleNode.textContent = value; } @@ -113,6 +134,10 @@ export const dynamicLoader = (() => { return styleNode; } + /** + * @param {string[]} urls + * @returns {Promise<void>} + */ function loadScripts(urls) { return new Promise((resolve, reject) => { const parent = document.body; @@ -136,12 +161,20 @@ export const dynamicLoader = (() => { }); } + /** + * @param {HTMLElement} parent + * @param {() => void} resolve + * @param {(reason?: unknown) => void} reject + */ function loadScriptSentinel(parent, resolve, reject) { const script = document.createElement('script'); const sentinelEventName = 'dynamicLoaderSentinel'; + /** + * @param {import('dynamic-loader').DynamicLoaderSentinelDetails} e + */ const sentinelEventCallback = (e) => { - if (e.script !== script.src) { return; } + if (e.scriptUrl !== script.src) { return; } yomitan.off(sentinelEventName, sentinelEventCallback); parent.removeChild(script); resolve(); @@ -159,6 +192,10 @@ export const dynamicLoader = (() => { } } + /** + * @param {string} value + * @returns {string} + */ function escapeCSSAttribute(value) { return value.replace(/['\\]/g, (character) => `\\${character}`); } diff --git a/ext/js/templates/__mocks__/template-renderer-proxy.js b/ext/js/templates/__mocks__/template-renderer-proxy.js index bcacf13b..8823e8f3 100644 --- a/ext/js/templates/__mocks__/template-renderer-proxy.js +++ b/ext/js/templates/__mocks__/template-renderer-proxy.js @@ -20,20 +20,35 @@ import {AnkiTemplateRenderer} from '../sandbox/anki-template-renderer.js'; export class TemplateRendererProxy { constructor() { + /** @type {?Promise<void>} */ this._preparePromise = null; + /** @type {AnkiTemplateRenderer} */ this._ankiTemplateRenderer = new AnkiTemplateRenderer(); } + /** + * @param {string} template + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {Promise<import('template-renderer').RenderResult>} + */ async render(template, data, type) { await this._prepare(); - return await this._ankiTemplateRenderer.templateRenderer.render(template, data, type); + return this._ankiTemplateRenderer.templateRenderer.render(template, data, type); } + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @returns {Promise<import('core').Response<import('template-renderer').RenderResult>[]>} + */ async renderMulti(items) { await this._prepare(); - return await this._serializeMulti(this._ankiTemplateRenderer.templateRenderer.renderMulti(items)); + return this._ankiTemplateRenderer.templateRenderer.renderMulti(items); } + /** + * @returns {Promise<void>} + */ _prepare() { if (this._preparePromise === null) { this._preparePromise = this._prepareInternal(); @@ -41,40 +56,8 @@ export class TemplateRendererProxy { return this._preparePromise; } + /** */ async _prepareInternal() { await this._ankiTemplateRenderer.prepare(); } - - _serializeError(error) { - try { - if (typeof error === 'object' && error !== null) { - const result = { - name: error.name, - message: error.message, - stack: error.stack - }; - if (Object.prototype.hasOwnProperty.call(error, 'data')) { - result.data = error.data; - } - return result; - } - } catch (e) { - // NOP - } - return { - value: error, - hasValue: true - }; - } - - _serializeMulti(array) { - for (let i = 0, ii = array.length; i < ii; ++i) { - const value = array[i]; - const {error} = value; - if (typeof error !== 'undefined') { - value.error = this._serializeError(error); - } - } - return array; - } } diff --git a/ext/js/templates/sandbox/anki-template-renderer-content-manager.js b/ext/js/templates/sandbox/anki-template-renderer-content-manager.js index 596fa499..4989ced3 100644 --- a/ext/js/templates/sandbox/anki-template-renderer-content-manager.js +++ b/ext/js/templates/sandbox/anki-template-renderer-content-manager.js @@ -17,30 +17,21 @@ */ /** - * A callback used when a media file has been loaded. - * @callback AnkiTemplateRendererContentManager.OnLoadCallback - * @param {string} url The URL of the media that was loaded. - */ - -/** - * A callback used when a media file should be unloaded. - * @callback AnkiTemplateRendererContentManager.OnUnloadCallback - * @param {boolean} fullyLoaded Whether or not the media was fully loaded. - */ - -/** * The content manager which is used when generating content for Anki. */ export class AnkiTemplateRendererContentManager { /** * Creates a new instance of the class. - * @param {TemplateRendererMediaProvider} mediaProvider The media provider for the object. - * @param {object} data The data object passed to the Handlebars template renderer. - * See {@link AnkiNoteDataCreator.create}'s return value for structure information. + * @param {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} mediaProvider The media provider for the object. + * @param {import('anki-templates').NoteData} data The data object passed to the Handlebars template renderer. + * See AnkiNoteDataCreator.create's return value for structure information. */ constructor(mediaProvider, data) { + /** @type {import('./template-renderer-media-provider.js').TemplateRendererMediaProvider} */ this._mediaProvider = mediaProvider; + /** @type {import('anki-templates').NoteData} */ this._data = data; + /** @type {import('anki-template-renderer-content-manager').OnUnloadCallback[]} */ this._onUnloadCallbacks = []; } @@ -48,9 +39,9 @@ export class AnkiTemplateRendererContentManager { * Attempts to load the media file from a given dictionary. * @param {string} path The path to the media file in the dictionary. * @param {string} dictionary The name of the dictionary. - * @param {AnkiTemplateRendererContentManager.OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. + * @param {import('anki-template-renderer-content-manager').OnLoadCallback} onLoad The callback that is executed if the media was loaded successfully. * No assumptions should be made about the synchronicity of this callback. - * @param {AnkiTemplateRendererContentManager.OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. + * @param {import('anki-template-renderer-content-manager').OnUnloadCallback} onUnload The callback that is executed when the media should be unloaded. */ loadMedia(path, dictionary, onLoad, onUnload) { const imageUrl = this._mediaProvider.getMedia(this._data, ['dictionaryMedia', path], {dictionary, format: 'fileName', default: null}); @@ -73,7 +64,7 @@ export class AnkiTemplateRendererContentManager { /** * Sets up attributes and events for a link element. - * @param {Element} element The link element. + * @param {HTMLAnchorElement} element The link element. * @param {string} href The URL. * @param {boolean} internal Whether or not the URL is an internal or external link. */ diff --git a/ext/js/templates/sandbox/anki-template-renderer.js b/ext/js/templates/sandbox/anki-template-renderer.js index 10f69745..dbf395e9 100644 --- a/ext/js/templates/sandbox/anki-template-renderer.js +++ b/ext/js/templates/sandbox/anki-template-renderer.js @@ -36,17 +36,29 @@ export class AnkiTemplateRenderer { * Creates a new instance of the class. */ constructor() { + /** @type {CssStyleApplier} */ this._structuredContentStyleApplier = new CssStyleApplier('/data/structured-content-style.json'); + /** @type {CssStyleApplier} */ this._pronunciationStyleApplier = new CssStyleApplier('/data/pronunciation-style.json'); + /** @type {RegExp} */ this._structuredContentDatasetKeyIgnorePattern = /^sc([^a-z]|$)/; + /** @type {JapaneseUtil} */ this._japaneseUtil = new JapaneseUtil(null); + /** @type {TemplateRenderer} */ this._templateRenderer = new TemplateRenderer(); + /** @type {AnkiNoteDataCreator} */ this._ankiNoteDataCreator = new AnkiNoteDataCreator(this._japaneseUtil); + /** @type {TemplateRendererMediaProvider} */ this._mediaProvider = new TemplateRendererMediaProvider(); + /** @type {PronunciationGenerator} */ this._pronunciationGenerator = new PronunciationGenerator(this._japaneseUtil); + /** @type {?(Map<string, unknown>[])} */ this._stateStack = null; + /** @type {?import('anki-note-builder').Requirement[]} */ this._requirements = null; - this._cleanupCallbacks = null; + /** @type {(() => void)[]} */ + this._cleanupCallbacks = []; + /** @type {?HTMLElement} */ this._temporaryElement = null; } @@ -93,7 +105,7 @@ export class AnkiTemplateRenderer { ]); this._templateRenderer.registerDataType('ankiNote', { modifier: ({marker, commonData}) => this._ankiNoteDataCreator.create(marker, commonData), - composeData: (marker, commonData) => ({marker, commonData}) + composeData: ({marker}, commonData) => ({marker, commonData}) }); this._templateRenderer.setRenderCallbacks( this._onRenderSetup.bind(this), @@ -107,40 +119,56 @@ export class AnkiTemplateRenderer { // Private + /** + * @returns {{requirements: import('anki-note-builder').Requirement[]}} + */ _onRenderSetup() { + /** @type {import('anki-note-builder').Requirement[]} */ const requirements = []; this._stateStack = [new Map()]; this._requirements = requirements; this._mediaProvider.requirements = requirements; - this._cleanupCallbacks = []; return {requirements}; } + /** + * @returns {void} + */ _onRenderCleanup() { for (const callback of this._cleanupCallbacks) { callback(); } this._stateStack = null; this._requirements = null; this._mediaProvider.requirements = null; - this._cleanupCallbacks = null; + this._cleanupCallbacks.length = 0; } + /** + * @param {string} text + * @returns {string} + */ _escape(text) { return Handlebars.Utils.escapeExpression(text); } + /** + * @param {string} text + * @returns {string} + */ _safeString(text) { return new Handlebars.SafeString(text); } // Template helpers - _dumpObject(context, object) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _dumpObject(object) { const dump = JSON.stringify(object, null, 4); return this._escape(dump); } - _furigana(context, ...args) { - const {expression, reading} = this._getFuriganaExpressionAndReading(context, ...args); + /** @type {import('template-renderer').HelperFunction<string>} */ + _furigana(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); const segs = this._japaneseUtil.distributeFurigana(expression, reading); let result = ''; @@ -157,8 +185,9 @@ export class AnkiTemplateRenderer { return this._safeString(result); } - _furiganaPlain(context, ...args) { - const {expression, reading} = this._getFuriganaExpressionAndReading(context, ...args); + /** @type {import('template-renderer').HelperFunction<string>} */ + _furiganaPlain(args, context, options) { + const {expression, reading} = this._getFuriganaExpressionAndReading(args, context, options); const segs = this._japaneseUtil.distributeFurigana(expression, reading); let result = ''; @@ -174,43 +203,56 @@ export class AnkiTemplateRenderer { return result; } - _getFuriganaExpressionAndReading(context, ...args) { - if (args.length >= 3) { - return {expression: args[0], reading: args[1]}; - } else if (args.length === 2) { - const {expression, reading} = args[0]; - return {expression, reading}; + /** + * @type {import('template-renderer').HelperFunction<{expression: string, reading: string}>} + */ + _getFuriganaExpressionAndReading(args) { + let expression; + let reading; + if (args.length >= 2) { + [expression, reading] = /** @type {[expression?: string, reading?: string]} */ (args); } else { - return void 0; + ({expression, reading} = /** @type {import('core').SerializableObject} */ (args[0])); } + return { + expression: typeof expression === 'string' ? expression : '', + reading: typeof reading === 'string' ? reading : '' + }; } + /** + * @param {string} string + * @returns {string} + */ _stringToMultiLineHtml(string) { return string.split('\n').join('<br>'); } - _multiLine(context, options) { - return this._stringToMultiLineHtml(options.fn(context)); + /** @type {import('template-renderer').HelperFunction<string>} */ + _multiLine(_args, context, options) { + return this._stringToMultiLineHtml(this._asString(options.fn(context))); } - _regexReplace(context, ...args) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _regexReplace(args, context, options) { // Usage: // {{#regexReplace regex string [flags] [content]...}}content{{/regexReplace}} // regex: regular expression string // string: string to replace // flags: optional flags for regular expression // e.g. "i" for case-insensitive, "g" for replace all - const argCount = args.length - 1; - const options = args[argCount]; - let value = typeof options.fn === 'function' ? options.fn(context) : ''; + const argCount = args.length; + let value = this._asString(options.fn(context)); if (argCount > 3) { value = `${args.slice(3, -1).join('')}${value}`; } if (argCount > 1) { try { - const flags = argCount > 2 ? args[2] : 'g'; - const regex = new RegExp(args[0], flags); - value = value.replace(regex, args[1]); + const [pattern, replacement, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + if (typeof replacement !== 'string') { throw new Error('Invalid replacement'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : 'g'); + value = value.replace(regex, replacement); } catch (e) { return `${e}`; } @@ -218,24 +260,26 @@ export class AnkiTemplateRenderer { return value; } - _regexMatch(context, ...args) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _regexMatch(args, context, options) { // Usage: // {{#regexMatch regex [flags] [content]...}}content{{/regexMatch}} // regex: regular expression string // flags: optional flags for regular expression // e.g. "i" for case-insensitive, "g" for match all - const argCount = args.length - 1; - const options = args[argCount]; - let value = typeof options.fn === 'function' ? options.fn(context) : ''; + const argCount = args.length; + let value = this._asString(options.fn(context)); if (argCount > 2) { value = `${args.slice(2, -1).join('')}${value}`; } if (argCount > 0) { try { - const flags = argCount > 1 ? args[1] : ''; - const regex = new RegExp(args[0], flags); + const [pattern, flags] = args; + if (typeof pattern !== 'string') { throw new Error('Invalid pattern'); } + const regex = new RegExp(pattern, typeof flags === 'string' ? flags : ''); + /** @type {string[]} */ const parts = []; - value.replace(regex, (g0) => parts.push(g0)); + value.replace(regex, (g0) => { parts.push(g0); return g0; }); value = parts.join(''); } catch (e) { return `${e}`; @@ -244,11 +288,18 @@ export class AnkiTemplateRenderer { return value; } - _mergeTags(context, object, isGroupMode, isMergeMode) { + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _mergeTags(args) { + const [object, isGroupMode, isMergeMode] = /** @type {[object: import('anki-templates').TermDictionaryEntry, isGroupMode: boolean, isMergeMode: boolean]} */ (args); const tagSources = []; if (isGroupMode || isMergeMode) { - for (const definition of object.definitions) { - tagSources.push(definition.definitionTags); + const {definitions} = object; + if (Array.isArray(definitions)) { + for (const definition of definitions) { + tagSources.push(definition.definitionTags); + } } } else { tagSources.push(object.definitionTags); @@ -265,7 +316,9 @@ export class AnkiTemplateRenderer { return [...tags].join(', '); } - _eachUpTo(context, iterable, maxCount, options) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _eachUpTo(args, context, options) { + const [iterable, maxCount] = /** @type {[iterable: Iterable<unknown>, maxCount: number]} */ (args); if (iterable) { const results = []; let any = false; @@ -279,14 +332,15 @@ export class AnkiTemplateRenderer { return results.join(''); } } - return options.inverse(context); + return this._asString(options.inverse(context)); } - _spread(context, ...args) { + /** @type {import('template-renderer').HelperFunction<unknown[]>} */ + _spread(args) { const result = []; - for (let i = 0, ii = args.length - 1; i < ii; ++i) { + for (const array of /** @type {Iterable<unknown>[]} */ (args)) { try { - result.push(...args[i]); + result.push(...array); } catch (e) { // NOP } @@ -294,15 +348,22 @@ export class AnkiTemplateRenderer { return result; } - _op(context, ...args) { + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _op(args) { + const [operator] = /** @type {[operator: string, operand1: import('core').SafeAny, operand2?: import('core').SafeAny, operand3?: import('core').SafeAny]} */ (args); switch (args.length) { - case 3: return this._evaluateUnaryExpression(args[0], args[1]); - case 4: return this._evaluateBinaryExpression(args[0], args[1], args[2]); - case 5: return this._evaluateTernaryExpression(args[0], args[1], args[2], args[3]); + case 2: return this._evaluateUnaryExpression(operator, args[1]); + case 3: return this._evaluateBinaryExpression(operator, args[1], args[2]); + case 4: return this._evaluateTernaryExpression(operator, args[1], args[2], args[3]); default: return void 0; } } + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @returns {unknown} + */ _evaluateUnaryExpression(operator, operand1) { switch (operator) { case '+': return +operand1; @@ -313,6 +374,12 @@ export class AnkiTemplateRenderer { } } + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @returns {unknown} + */ _evaluateBinaryExpression(operator, operand1, operand2) { switch (operator) { case '+': return operand1 + operand2; @@ -341,6 +408,13 @@ export class AnkiTemplateRenderer { } } + /** + * @param {string} operator + * @param {import('core').SafeAny} operand1 + * @param {import('core').SafeAny} operand2 + * @param {import('core').SafeAny} operand3 + * @returns {unknown} + */ _evaluateTernaryExpression(operator, operand1, operand2, operand3) { switch (operator) { case '?:': return operand1 ? operand2 : operand3; @@ -348,8 +422,11 @@ export class AnkiTemplateRenderer { } } - _get(context, key) { + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _get(args) { + const [key] = /** @type {[key: string]} */ (args); const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } for (let i = stateStack.length; --i >= 0;) { const map = stateStack[i]; if (map.has(key)) { @@ -359,19 +436,21 @@ export class AnkiTemplateRenderer { return void 0; } - _set(context, ...args) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _set(args, context, options) { const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } switch (args.length) { - case 2: + case 1: { - const [key, options] = args; + const [key] = /** @type {[key: string]} */ (args); const value = options.fn(context); stateStack[stateStack.length - 1].set(key, value); } break; - case 3: + case 2: { - const [key, value] = args; + const [key, value] = /** @type {[key: string, value: unknown]} */ (args); stateStack[stateStack.length - 1].set(key, value); } break; @@ -379,8 +458,10 @@ export class AnkiTemplateRenderer { return ''; } - _scope(context, options) { + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _scope(_args, context, options) { const stateStack = this._stateStack; + if (stateStack === null) { throw new Error('Invalid state'); } try { stateStack.push(new Map()); return options.fn(context); @@ -391,14 +472,25 @@ export class AnkiTemplateRenderer { } } - _property(context, ...args) { - const ii = args.length - 1; + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _property(args) { + const ii = args.length; if (ii <= 0) { return void 0; } try { let value = args[0]; for (let i = 1; i < ii; ++i) { - value = value[args[i]]; + if (typeof value !== 'object' || value === null) { throw new Error('Invalid object'); } + const key = args[i]; + switch (typeof key) { + case 'number': + case 'string': + case 'symbol': + break; + default: + throw new Error('Invalid key'); + } + value = /** @type {import('core').UnknownObject} */ (value)[key]; } return value; } catch (e) { @@ -406,38 +498,51 @@ export class AnkiTemplateRenderer { } } - _noop(context, options) { + /** @type {import('template-renderer').HelperFunction<unknown>} */ + _noop(_args, context, options) { return options.fn(context); } - _isMoraPitchHigh(context, index, position) { + /** @type {import('template-renderer').HelperFunction<boolean>} */ + _isMoraPitchHigh(args) { + const [index, position] = /** @type {[index: number, position: number]} */ (args); return this._japaneseUtil.isMoraPitchHigh(index, position); } - _getKanaMorae(context, text) { + /** @type {import('template-renderer').HelperFunction<string[]>} */ + _getKanaMorae(args) { + const [text] = /** @type {[text: string]} */ (args); return this._japaneseUtil.getKanaMorae(`${text}`); } - _getTypeof(context, ...args) { - const ii = args.length - 1; - const value = (ii > 0 ? args[0] : args[ii].fn(context)); + /** @type {import('template-renderer').HelperFunction<import('core').TypeofResult>} */ + _getTypeof(args, context, options) { + const ii = args.length; + const value = (ii > 0 ? args[0] : options.fn(context)); return typeof value; } - _join(context, ...args) { - return args.length > 1 ? args.slice(1, args.length - 1).flat().join(args[0]) : ''; + /** @type {import('template-renderer').HelperFunction<string>} */ + _join(args) { + return args.length > 0 ? args.slice(1, args.length).flat().join(/** @type {string} */ (args[0])) : ''; } - _concat(context, ...args) { + /** @type {import('template-renderer').HelperFunction<string>} */ + _concat(args) { let result = ''; - for (let i = 0, ii = args.length - 1; i < ii; ++i) { + for (let i = 0, ii = args.length; i < ii; ++i) { result += args[i]; } return result; } - _pitchCategories(context, data) { - const {pronunciations, headwords} = data.dictionaryEntry; + /** @type {import('template-renderer').HelperFunction<string[]>} */ + _pitchCategories(args) { + const [data] = /** @type {[data: import('anki-templates').NoteData]} */ (args); + const {dictionaryEntry} = data; + if (dictionaryEntry.type !== 'term') { return []; } + const {pronunciations, headwords} = dictionaryEntry; + /** @type {Set<string>} */ const categories = new Set(); for (const {headwordIndex, pitches} of pronunciations) { const {reading, wordClasses} = headwords[headwordIndex]; @@ -452,6 +557,9 @@ export class AnkiTemplateRenderer { return [...categories]; } + /** + * @returns {HTMLElement} + */ _getTemporaryElement() { let element = this._temporaryElement; if (element === null) { @@ -461,14 +569,28 @@ export class AnkiTemplateRenderer { return element; } + /** + * @param {Element} node + * @returns {string} + */ _getStructuredContentHtml(node) { return this._getHtml(node, this._structuredContentStyleApplier, this._structuredContentDatasetKeyIgnorePattern); } + /** + * @param {Element} node + * @returns {string} + */ _getPronunciationHtml(node) { return this._getHtml(node, this._pronunciationStyleApplier, null); } + /** + * @param {Element} node + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + * @returns {string} + */ _getHtml(node, styleApplier, datasetKeyIgnorePattern) { const container = this._getTemporaryElement(); container.appendChild(node); @@ -478,20 +600,27 @@ export class AnkiTemplateRenderer { return this._safeString(result); } + /** + * @param {Element} root + * @param {CssStyleApplier} styleApplier + * @param {?RegExp} datasetKeyIgnorePattern + */ _normalizeHtml(root, styleApplier, datasetKeyIgnorePattern) { const {ELEMENT_NODE, TEXT_NODE} = Node; const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT); + /** @type {HTMLElement[]} */ const elements = []; + /** @type {Text[]} */ const textNodes = []; while (true) { const node = treeWalker.nextNode(); if (node === null) { break; } switch (node.nodeType) { case ELEMENT_NODE: - elements.push(node); + elements.push(/** @type {HTMLElement} */ (node)); break; case TEXT_NODE: - textNodes.push(node); + textNodes.push(/** @type {Text} */ (node)); break; } } @@ -508,8 +637,11 @@ export class AnkiTemplateRenderer { } } + /** + * @param {Text} textNode + */ _replaceNewlines(textNode) { - const parts = textNode.nodeValue.split('\n'); + const parts = /** @type {string} */ (textNode.nodeValue).split('\n'); if (parts.length <= 1) { return; } const {parentNode} = textNode; if (parentNode === null) { return; } @@ -521,6 +653,10 @@ export class AnkiTemplateRenderer { parentNode.replaceChild(fragment, textNode); } + /** + * @param {import('anki-templates').NoteData} data + * @returns {StructuredContentGenerator} + */ _createStructuredContentGenerator(data) { const contentManager = new AnkiTemplateRendererContentManager(this._mediaProvider, data); const instance = new StructuredContentGenerator(contentManager, this._japaneseUtil, document); @@ -528,44 +664,64 @@ export class AnkiTemplateRenderer { return instance; } - _formatGlossary(context, dictionary, content, options) { + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _formatGlossary(args, context, options) { + const [dictionary, content] = /** @type {[dictionary: string, content: import('dictionary-data').TermGlossary]} */ (args); const data = options.data.root; if (typeof content === 'string') { return this._stringToMultiLineHtml(this._escape(content)); } if (!(typeof content === 'object' && content !== null)) { return ''; } switch (content.type) { case 'image': return this._formatGlossaryImage(content, dictionary, data); case 'structured-content': return this._formatStructuredContent(content, dictionary, data); + case 'text': return this._stringToMultiLineHtml(this._escape(content.text)); } return ''; } + /** + * @param {import('dictionary-data').TermGlossaryImage} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ _formatGlossaryImage(content, dictionary, data) { const structuredContentGenerator = this._createStructuredContentGenerator(data); const node = structuredContentGenerator.createDefinitionImage(content, dictionary); return this._getStructuredContentHtml(node); } + /** + * @param {import('dictionary-data').TermGlossaryStructuredContent} content + * @param {string} dictionary + * @param {import('anki-templates').NoteData} data + * @returns {string} + */ _formatStructuredContent(content, dictionary, data) { const structuredContentGenerator = this._createStructuredContentGenerator(data); const node = structuredContentGenerator.createStructuredContent(content.content, dictionary); return node !== null ? this._getStructuredContentHtml(node) : ''; } - _hasMedia(context, ...args) { - const ii = args.length - 1; - const options = args[ii]; - return this._mediaProvider.hasMedia(options.data.root, args.slice(0, ii), options.hash); + /** + * @type {import('template-renderer').HelperFunction<boolean>} + */ + _hasMedia(args, _context, options) { + return this._mediaProvider.hasMedia(options.data.root, args, options.hash); } - _getMedia(context, ...args) { - const ii = args.length - 1; - const options = args[ii]; - return this._mediaProvider.getMedia(options.data.root, args.slice(0, ii), options.hash); + /** + * @type {import('template-renderer').HelperFunction<?string>} + */ + _getMedia(args, _context, options) { + return this._mediaProvider.getMedia(options.data.root, args, options.hash); } - _pronunciation(context, ...args) { - const ii = args.length - 1; - const options = args[ii]; + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _pronunciation(_args, _context, options) { let {format, reading, downstepPosition, nasalPositions, devoicePositions} = options.hash; if (typeof reading !== 'string' || reading.length === 0) { return ''; } @@ -586,18 +742,30 @@ export class AnkiTemplateRenderer { } } - _hiragana(context, ...args) { - const ii = args.length - 1; - const options = args[ii]; + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _hiragana(args, context, options) { + const ii = args.length; const {keepProlongedSoundMarks} = options.hash; const value = (ii > 0 ? args[0] : options.fn(context)); - return this._japaneseUtil.convertKatakanaToHiragana(value, keepProlongedSoundMarks === true); + return typeof value === 'string' ? this._japaneseUtil.convertKatakanaToHiragana(value, keepProlongedSoundMarks === true) : ''; } - _katakana(context, ...args) { - const ii = args.length - 1; - const options = args[ii]; + /** + * @type {import('template-renderer').HelperFunction<string>} + */ + _katakana(args, context, options) { + const ii = args.length; const value = (ii > 0 ? args[0] : options.fn(context)); - return this._japaneseUtil.convertHiraganaToKatakana(value); + return typeof value === 'string' ? this._japaneseUtil.convertHiraganaToKatakana(value) : ''; + } + + /** + * @param {unknown} value + * @returns {string} + */ + _asString(value) { + return typeof value === 'string' ? value : `${value}`; } } diff --git a/ext/js/templates/sandbox/template-renderer-frame-api.js b/ext/js/templates/sandbox/template-renderer-frame-api.js index 7ce2d909..94ebf7fe 100644 --- a/ext/js/templates/sandbox/template-renderer-frame-api.js +++ b/ext/js/templates/sandbox/template-renderer-frame-api.js @@ -16,16 +16,26 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ +import {ExtensionError} from '../../core/extension-error.js'; + export class TemplateRendererFrameApi { + /** + * @param {import('./template-renderer.js').TemplateRenderer} templateRenderer + */ constructor(templateRenderer) { + /** @type {import('./template-renderer.js').TemplateRenderer} */ this._templateRenderer = templateRenderer; - this._windowMessageHandlers = new Map([ + /** @type {import('core').MessageHandlerMap} */ + this._windowMessageHandlers = new Map(/** @type {import('core').MessageHandlerArray} */ ([ ['render', {async: false, handler: this._onRender.bind(this)}], ['renderMulti', {async: false, handler: this._onRenderMulti.bind(this)}], ['getModifiedData', {async: false, handler: this._onGetModifiedData.bind(this)}] - ]); + ])); } + /** + * @returns {void} + */ prepare() { window.addEventListener('message', this._onWindowMessage.bind(this), false); this._postMessage(window.parent, 'ready', {}, null); @@ -33,14 +43,24 @@ export class TemplateRendererFrameApi { // Private + /** + * @param {MessageEvent<import('template-renderer-frame-api').MessageData>} e + */ _onWindowMessage(e) { const {source, data: {action, params, id}} = e; const messageHandler = this._windowMessageHandlers.get(action); if (typeof messageHandler === 'undefined') { return; } - this._onWindowMessageInner(messageHandler, action, params, source, id); + this._onWindowMessageInner(messageHandler, action, params, /** @type {Window} */ (source), id); } + /** + * @param {import('core').MessageHandlerDetails} handlerItem + * @param {string} action + * @param {import('core').SerializableObject} params + * @param {Window} source + * @param {?string} id + */ async _onWindowMessageInner({handler, async}, action, params, source, id) { let response; try { @@ -50,64 +70,54 @@ export class TemplateRendererFrameApi { } response = {result}; } catch (error) { - response = {error: this._serializeError(error)}; + response = {error: ExtensionError.serialize(error)}; } if (typeof id === 'undefined') { return; } this._postMessage(source, `${action}.response`, response, id); } + /** + * @param {{template: string, data: import('template-renderer').PartialOrCompositeRenderData, type: import('anki-templates').RenderMode}} event + * @returns {import('template-renderer').RenderResult} + */ _onRender({template, data, type}) { return this._templateRenderer.render(template, data, type); } + /** + * @param {{items: import('template-renderer').RenderMultiItem[]}} event + * @returns {import('core').Response<import('template-renderer').RenderResult>[]} + */ _onRenderMulti({items}) { - return this._serializeMulti(this._templateRenderer.renderMulti(items)); + return this._templateRenderer.renderMulti(items); } + /** + * @param {{data: import('template-renderer').CompositeRenderData, type: import('anki-templates').RenderMode}} event + * @returns {import('anki-templates').NoteData} + */ _onGetModifiedData({data, type}) { const result = this._templateRenderer.getModifiedData(data, type); return this._clone(result); } - _serializeError(error) { - try { - if (typeof error === 'object' && error !== null) { - const result = { - name: error.name, - message: error.message, - stack: error.stack - }; - if (Object.prototype.hasOwnProperty.call(error, 'data')) { - result.data = error.data; - } - return result; - } - } catch (e) { - // NOP - } - return { - value: error, - hasValue: true - }; - } - - _serializeMulti(array) { - for (let i = 0, ii = array.length; i < ii; ++i) { - const value = array[i]; - const {error} = value; - if (typeof error !== 'undefined') { - value.error = this._serializeError(error); - } - } - return array; - } - + /** + * @template [T=unknown] + * @param {T} value + * @returns {T} + */ _clone(value) { return JSON.parse(JSON.stringify(value)); } + /** + * @param {Window} target + * @param {string} action + * @param {import('core').SerializableObject} params + * @param {?string} id + */ _postMessage(target, action, params, id) { - return target.postMessage({action, params, id}, '*'); + target.postMessage(/** @type {import('template-renderer-frame-api').MessageData} */ ({action, params, id}), '*'); } } diff --git a/ext/js/templates/sandbox/template-renderer-media-provider.js b/ext/js/templates/sandbox/template-renderer-media-provider.js index 33ddec21..d8a0a16d 100644 --- a/ext/js/templates/sandbox/template-renderer-media-provider.js +++ b/ext/js/templates/sandbox/template-renderer-media-provider.js @@ -20,9 +20,11 @@ import {Handlebars} from '../../../lib/handlebars.js'; export class TemplateRendererMediaProvider { constructor() { + /** @type {?import('anki-note-builder').Requirement[]} */ this._requirements = null; } + /** @type {?import('anki-note-builder').Requirement[]} */ get requirements() { return this._requirements; } @@ -31,12 +33,24 @@ export class TemplateRendererMediaProvider { this._requirements = value; } + /** + * @param {import('anki-templates').NoteData} root + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {boolean} + */ hasMedia(root, args, namedArgs) { const {media} = root; const data = this._getMediaData(media, args, namedArgs); return (data !== null); } + /** + * @param {import('anki-templates').NoteData} root + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {?string} + */ getMedia(root, args, namedArgs) { const {media} = root; const data = this._getMediaData(media, args, namedArgs); @@ -45,16 +59,24 @@ export class TemplateRendererMediaProvider { if (typeof result === 'string') { return result; } } const defaultValue = namedArgs.default; - return typeof defaultValue !== 'undefined' ? defaultValue : ''; + return defaultValue === null || typeof defaultValue === 'string' ? defaultValue : ''; } // Private + /** + * @param {import('anki-note-builder').Requirement} value + */ _addRequirement(value) { if (this._requirements === null) { return; } this._requirements.push(value); } + /** + * @param {import('anki-templates').MediaObject} data + * @param {import('core').SerializableObject} namedArgs + * @returns {string} + */ _getFormattedValue(data, namedArgs) { let {value} = data; const {escape=true} = namedArgs; @@ -64,6 +86,12 @@ export class TemplateRendererMediaProvider { return value; } + /** + * @param {import('anki-templates').Media} media + * @param {unknown[]} args + * @param {import('core').SerializableObject} namedArgs + * @returns {?(import('anki-templates').MediaObject)} + */ _getMediaData(media, args, namedArgs) { const type = args[0]; switch (type) { @@ -78,6 +106,11 @@ export class TemplateRendererMediaProvider { } } + /** + * @param {import('anki-templates').Media} media + * @param {import('anki-templates').MediaSimpleType} type + * @returns {?import('anki-templates').MediaObject} + */ _getSimpleMediaData(media, type) { const result = media[type]; if (typeof result === 'object' && result !== null) { return result; } @@ -85,12 +118,19 @@ export class TemplateRendererMediaProvider { return null; } + /** + * @param {import('anki-templates').Media} media + * @param {unknown} path + * @param {import('core').SerializableObject} namedArgs + * @returns {?import('anki-templates').MediaObject} + */ _getDictionaryMedia(media, path, namedArgs) { + if (typeof path !== 'string') { return null; } const {dictionaryMedia} = media; const {dictionary} = namedArgs; + if (typeof dictionary !== 'string') { return null; } if ( typeof dictionaryMedia !== 'undefined' && - typeof dictionary === 'string' && Object.prototype.hasOwnProperty.call(dictionaryMedia, dictionary) ) { const dictionaryMedia2 = dictionaryMedia[dictionary]; @@ -109,8 +149,15 @@ export class TemplateRendererMediaProvider { return null; } + /** + * @param {import('anki-templates').Media} media + * @param {unknown} text + * @param {import('core').SerializableObject} namedArgs + * @returns {?import('anki-templates').MediaObject} + */ _getTextFurigana(media, text, namedArgs) { - const {readingMode=null} = namedArgs; + if (typeof text !== 'string') { return null; } + const readingMode = this._normalizeReadingMode(namedArgs.readingMode); const {textFurigana} = media; if (Array.isArray(textFurigana)) { for (const entry of textFurigana) { @@ -125,4 +172,18 @@ export class TemplateRendererMediaProvider { }); return null; } + + /** + * @param {unknown} value + * @returns {?import('anki-templates').TextFuriganaReadingMode} + */ + _normalizeReadingMode(value) { + switch (value) { + case 'hiragana': + case 'katakana': + return value; + default: + return null; + } + } } diff --git a/ext/js/templates/sandbox/template-renderer.js b/ext/js/templates/sandbox/template-renderer.js index 8d8a2765..239240b6 100644 --- a/ext/js/templates/sandbox/template-renderer.js +++ b/ext/js/templates/sandbox/template-renderer.js @@ -17,50 +17,78 @@ */ import {Handlebars} from '../../../lib/handlebars.js'; +import {ExtensionError} from '../../core/extension-error.js'; export class TemplateRenderer { constructor() { + /** @type {Map<string, import('handlebars').TemplateDelegate<import('anki-templates').NoteData>>} */ this._cache = new Map(); + /** @type {number} */ this._cacheMaxSize = 5; + /** @type {Map<import('anki-templates').RenderMode, import('template-renderer').DataType>} */ this._dataTypes = new Map(); + /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} */ this._renderSetup = null; + /** @type {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} */ this._renderCleanup = null; } + /** + * @param {import('template-renderer').HelperFunctionsDescriptor} helpers + */ registerHelpers(helpers) { for (const [name, helper] of helpers) { this._registerHelper(name, helper); } } - registerDataType(name, {modifier=null, composeData=null}) { + /** + * @param {import('anki-templates').RenderMode} name + * @param {import('template-renderer').DataType} details + */ + registerDataType(name, {modifier, composeData}) { this._dataTypes.set(name, {modifier, composeData}); } + /** + * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').SetupCallbackResult)} setup + * @param {?((noteData: import('anki-templates').NoteData) => import('template-renderer').CleanupCallbackResult)} cleanup + */ setRenderCallbacks(setup, cleanup) { this._renderSetup = setup; this._renderCleanup = cleanup; } + /** + * @param {string} template + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {import('template-renderer').RenderResult} + */ render(template, data, type) { const instance = this._getTemplateInstance(template); - data = this._getModifiedData(data, void 0, type); - return this._renderTemplate(instance, data); + const modifiedData = this._getModifiedData(data, void 0, type); + return this._renderTemplate(instance, modifiedData); } + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @returns {import('core').Response<import('template-renderer').RenderResult>[]} + */ renderMulti(items) { + /** @type {import('core').Response<import('template-renderer').RenderResult>[]} */ const results = []; for (const {template, templateItems} of items) { const instance = this._getTemplateInstance(template); for (const {type, commonData, datas} of templateItems) { - for (let data of datas) { + for (const data of datas) { let result; try { - data = this._getModifiedData(data, commonData, type); - result = this._renderTemplate(instance, data); - result = {result}; + const data2 = this._getModifiedData(data, commonData, type); + const renderResult = this._renderTemplate(instance, data2); + result = {result: renderResult}; } catch (error) { - result = {error}; + result = {error: ExtensionError.serialize(error)}; } results.push(result); } @@ -69,29 +97,46 @@ export class TemplateRenderer { return results; } + /** + * @param {import('template-renderer').CompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {import('anki-templates').NoteData} + */ getModifiedData(data, type) { return this._getModifiedData(data, void 0, type); } // Private + /** + * @param {string} template + * @returns {import('handlebars').TemplateDelegate<import('anki-templates').NoteData>} + */ _getTemplateInstance(template) { const cache = this._cache; let instance = cache.get(template); if (typeof instance === 'undefined') { this._updateCacheSize(this._cacheMaxSize - 1); - instance = Handlebars.compileAST(template); + instance = /** @type {import('handlebars').TemplateDelegate<import('anki-templates').NoteData>} */ (Handlebars.compileAST(template)); cache.set(template, instance); } return instance; } + /** + * @param {import('handlebars').TemplateDelegate<import('anki-templates').NoteData>} instance + * @param {import('anki-templates').NoteData} data + * @returns {import('template-renderer').RenderResult} + */ _renderTemplate(instance, data) { const renderSetup = this._renderSetup; const renderCleanup = this._renderCleanup; + /** @type {string} */ let result; + /** @type {?import('template-renderer').SetupCallbackResult} */ let additions1; + /** @type {?import('template-renderer').CleanupCallbackResult} */ let additions2; try { additions1 = (typeof renderSetup === 'function' ? renderSetup(data) : null); @@ -99,28 +144,36 @@ export class TemplateRenderer { } finally { additions2 = (typeof renderCleanup === 'function' ? renderCleanup(data) : null); } - return Object.assign({result}, additions1, additions2); + return /** @type {import('template-renderer').RenderResult} */ (Object.assign({result}, additions1, additions2)); } + /** + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-note-builder').CommonData|undefined} commonData + * @param {import('anki-templates').RenderMode} type + * @returns {import('anki-templates').NoteData} + * @throws {Error} + */ _getModifiedData(data, commonData, type) { if (typeof type === 'string') { const typeInfo = this._dataTypes.get(type); if (typeof typeInfo !== 'undefined') { if (typeof commonData !== 'undefined') { const {composeData} = typeInfo; - if (typeof composeData === 'function') { - data = composeData(data, commonData); - } + data = composeData(data, commonData); + } else if (typeof data.commonData === 'undefined') { + throw new Error('Incomplete data'); } const {modifier} = typeInfo; - if (typeof modifier === 'function') { - data = modifier(data); - } + return modifier(/** @type {import('template-renderer').CompositeRenderData} */ (data)); } } - return data; + throw new Error(`Invalid type: ${type}`); } + /** + * @param {number} maxSize + */ _updateCacheSize(maxSize) { const cache = this._cache; let removeCount = cache.size - maxSize; @@ -132,9 +185,21 @@ export class TemplateRenderer { } } + /** + * @param {string} name + * @param {import('template-renderer').HelperFunction} helper + */ _registerHelper(name, helper) { + /** + * @this {unknown} + * @param {unknown[]} args + * @returns {unknown} + */ function wrapper(...args) { - return helper(this, ...args); + const argCountM1 = Math.max(0, args.length - 1); + const options = /** @type {import('handlebars').HelperOptions} */ (args[argCountM1]); + args.length = argCountM1; + return helper(args, this, options); } Handlebars.registerHelper(name, wrapper); } diff --git a/ext/js/templates/template-patcher.js b/ext/js/templates/template-patcher.js index 6bc012bc..33abb08e 100644 --- a/ext/js/templates/template-patcher.js +++ b/ext/js/templates/template-patcher.js @@ -18,12 +18,20 @@ export class TemplatePatcher { constructor() { + /** @type {RegExp} */ this._diffPattern1 = /\n?\{\{<<<<<<<\}\}\n/g; + /** @type {RegExp} */ this._diffPattern2 = /\n\{\{=======\}\}\n/g; + /** @type {RegExp} */ this._diffPattern3 = /\n\{\{>>>>>>>\}\}\n*/g; + /** @type {RegExp} */ this._lookupMarkerPattern = /[ \t]*\{\{~?>\s*\(\s*lookup\s*\.\s*"marker"\s*\)\s*~?\}\}/g; } + /** + * @param {string} content + * @returns {import('template-patcher').Patch} + */ parsePatch(content) { const diffPattern1 = this._diffPattern1; const diffPattern2 = this._diffPattern2; @@ -61,6 +69,11 @@ export class TemplatePatcher { return {addition: content, modifications}; } + /** + * @param {string} template + * @param {import('template-patcher').Patch} patch + * @returns {string} + */ applyPatch(template, patch) { for (const {current, replacement} of patch.modifications) { let fromIndex = 0; @@ -77,6 +90,11 @@ export class TemplatePatcher { // Private + /** + * @param {string} template + * @param {string} addition + * @returns {string} + */ _addFieldTemplatesBeforeEnd(template, addition) { if (addition.length === 0) { return template; } const newline = '\n'; diff --git a/ext/js/templates/template-renderer-proxy.js b/ext/js/templates/template-renderer-proxy.js index c46d05f9..642eea8b 100644 --- a/ext/js/templates/template-renderer-proxy.js +++ b/ext/js/templates/template-renderer-proxy.js @@ -16,35 +16,60 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, generateId} from '../core.js'; +import {generateId} from '../core.js'; +import {ExtensionError} from '../core/extension-error.js'; export class TemplateRendererProxy { constructor() { + /** @type {?HTMLIFrameElement} */ this._frame = null; + /** @type {boolean} */ this._frameNeedsLoad = true; + /** @type {boolean} */ this._frameLoading = false; + /** @type {?Promise<void>} */ this._frameLoadPromise = null; + /** @type {string} */ this._frameUrl = chrome.runtime.getURL('/template-renderer.html'); + /** @type {Set<{cancel: () => void}>} */ this._invocations = new Set(); } + /** + * @param {string} template + * @param {import('template-renderer').PartialOrCompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {Promise<import('template-renderer').RenderResult>} + */ async render(template, data, type) { await this._prepareFrame(); - return await this._invoke('render', {template, data, type}); + return /** @type {import('template-renderer').RenderResult} */ (await this._invoke('render', {template, data, type})); } + /** + * @param {import('template-renderer').RenderMultiItem[]} items + * @returns {Promise<import('core').Response<import('template-renderer').RenderResult>[]>} + */ async renderMulti(items) { await this._prepareFrame(); - return await this._invoke('renderMulti', {items}); + return /** @type {import('core').Response<import('template-renderer').RenderResult>[]} */ (await this._invoke('renderMulti', {items})); } + /** + * @param {import('template-renderer').CompositeRenderData} data + * @param {import('anki-templates').RenderMode} type + * @returns {Promise<import('anki-templates').NoteData>} + */ async getModifiedData(data, type) { await this._prepareFrame(); - return await this._invoke('getModifiedData', {data, type}); + return /** @type {import('anki-templates').NoteData} */ (await this._invoke('getModifiedData', {data, type})); } // Private + /** + * @returns {Promise<void>} + */ async _prepareFrame() { if (this._frame === null) { this._frame = document.createElement('iframe'); @@ -68,6 +93,12 @@ export class TemplateRendererProxy { await this._frameLoadPromise; } + /** + * @param {HTMLIFrameElement} frame + * @param {string} url + * @param {number} [timeout] + * @returns {Promise<void>} + */ _loadFrame(frame, url, timeout=5000) { return new Promise((resolve, reject) => { let state = 0x0; // 0x1 = frame added; 0x2 = frame loaded; 0x4 = frame ready @@ -79,6 +110,9 @@ export class TemplateRendererProxy { timer = null; } }; + /** + * @param {number} flags + */ const updateState = (flags) => { state |= flags; if (state !== 0x7) { return; } @@ -89,16 +123,20 @@ export class TemplateRendererProxy { if ((state & 0x3) !== 0x1) { return; } updateState(0x2); }; + /** + * @param {MessageEvent<unknown>} e + */ const onWindowMessage = (e) => { if ((state & 0x5) !== 0x1) { return; } const frameWindow = frame.contentWindow; if (frameWindow === null || frameWindow !== e.source) { return; } const {data} = e; - if (!(typeof data === 'object' && data !== null && data.action === 'ready')) { return; } + if (!(typeof data === 'object' && data !== null && /** @type {import('core').SerializableObject} */ (data).action === 'ready')) { return; } updateState(0x4); }; - let timer = setTimeout(() => { + /** @type {?number} */ + let timer = window.setTimeout(() => { timer = null; cleanup(); reject(new Error('Timeout')); @@ -111,7 +149,9 @@ export class TemplateRendererProxy { try { document.body.appendChild(frame); state = 0x1; - frame.contentDocument.location.href = url; + const {contentDocument} = frame; + if (contentDocument === null) { throw new Error('Failed to initialize frame URL'); } + contentDocument.location.href = url; } catch (e) { cleanup(); reject(e); @@ -119,6 +159,12 @@ export class TemplateRendererProxy { }); } + /** + * @param {string} action + * @param {import('core').SerializableObject} params + * @param {?number} [timeout] + * @returns {Promise<unknown>} + */ _invoke(action, params, timeout=null) { return new Promise((resolve, reject) => { const frameWindow = (this._frame !== null ? this._frame.contentWindow : null); @@ -143,22 +189,30 @@ export class TemplateRendererProxy { timer = null; } }; - const onMessage = (e) => { + /** + * @param {MessageEvent<unknown>} event + */ + const onMessage = (event) => { + if (event.source !== frameWindow) { return; } + const {data} = event; if ( - e.source !== frameWindow || - e.data.id !== id || - e.data.action !== `${action}.response` + typeof data !== 'object' || + data === null || + /** @type {import('core').SerializableObject} */ (data).id !== id || + /** @type {import('core').SerializableObject} */ (data).action !== `${action}.response` ) { return; } - const response = e.data.params; + const response = /** @type {import('core').SerializableObject} */ (data).params; + if (typeof response !== 'object' || response === null) { return; } + cleanup(); - const {error} = response; + const {error} = /** @type {import('core').Response} */ (response); if (error) { - reject(deserializeError(error)); + reject(ExtensionError.deserialize(error)); } else { - resolve(response.result); + resolve(/** @type {import('core').Response} */ (response).result); } }; @@ -174,6 +228,9 @@ export class TemplateRendererProxy { }); } + /** + * @returns {void} + */ _onFrameLoad() { if (this._frameLoading) { return; } this._frameNeedsLoad = true; diff --git a/ext/js/yomitan.js b/ext/js/yomitan.js index 5535aeb6..7cf67aec 100644 --- a/ext/js/yomitan.js +++ b/ext/js/yomitan.js @@ -18,7 +18,8 @@ import {API} from './comm/api.js'; import {CrossFrameAPI} from './comm/cross-frame-api.js'; -import {EventDispatcher, deferPromise, invokeMessageHandler, log, serializeError} from './core.js'; +import {EventDispatcher, deferPromise, invokeMessageHandler, log} from './core.js'; +import {ExtensionError} from './core/extension-error.js'; // Set up chrome alias if it's not available (Edge Legacy) if ((() => { @@ -36,51 +37,66 @@ if ((() => { } return (hasBrowser && !hasChrome); })()) { + // @ts-expect-error - objects should have roughly the same interface chrome = browser; } /** * The Yomitan class is a core component through which various APIs are handled and invoked. + * @augments EventDispatcher<import('extension').ExtensionEventType> */ -class Yomitan extends EventDispatcher { +export class Yomitan extends EventDispatcher { /** * Creates a new instance. The instance should not be used until it has been fully prepare()'d. */ constructor() { super(); + /** @type {string} */ + this._extensionName = 'Yomitan'; try { const manifest = chrome.runtime.getManifest(); this._extensionName = `${manifest.name} v${manifest.version}`; } catch (e) { - this._extensionName = 'Yomitan'; + // NOP } + /** @type {?string} */ + this._extensionUrlBase = null; try { this._extensionUrlBase = chrome.runtime.getURL('/'); } catch (e) { - this._extensionUrlBase = null; + // NOP } + /** @type {?boolean} */ this._isBackground = null; + /** @type {?API} */ this._api = null; + /** @type {?CrossFrameAPI} */ this._crossFrame = null; + /** @type {boolean} */ this._isExtensionUnloaded = false; + /** @type {boolean} */ this._isTriggeringExtensionUnloaded = false; + /** @type {boolean} */ this._isReady = false; - const {promise, resolve} = deferPromise(); + const {promise, resolve} = /** @type {import('core').DeferredPromiseDetails<void>} */ (deferPromise()); + /** @type {Promise<void>} */ this._isBackendReadyPromise = promise; + /** @type {?(() => void)} */ this._isBackendReadyPromiseResolve = resolve; - this._messageHandlers = new Map([ + /** @type {import('core').MessageHandlerMap} */ + this._messageHandlers = new Map(/** @type {import('core').MessageHandlerArray} */ ([ ['Yomitan.isReady', {async: false, handler: this._onMessageIsReady.bind(this)}], ['Yomitan.backendReady', {async: false, handler: this._onMessageBackendReady.bind(this)}], ['Yomitan.getUrl', {async: false, handler: this._onMessageGetUrl.bind(this)}], ['Yomitan.optionsUpdated', {async: false, handler: this._onMessageOptionsUpdated.bind(this)}], ['Yomitan.databaseUpdated', {async: false, handler: this._onMessageDatabaseUpdated.bind(this)}], ['Yomitan.zoomChanged', {async: false, handler: this._onMessageZoomChanged.bind(this)}] - ]); + ])); } /** @@ -88,7 +104,8 @@ class Yomitan extends EventDispatcher { * @type {boolean} */ get isBackground() { - return this._isBackground; + if (this._isBackground === null) { throw new Error('Not prepared'); } + return /** @type {boolean} */ (this._isBackground); } /** @@ -105,6 +122,7 @@ class Yomitan extends EventDispatcher { * @type {API} */ get api() { + if (this._api === null) { throw new Error('Not prepared'); } return this._api; } @@ -114,6 +132,7 @@ class Yomitan extends EventDispatcher { * @type {CrossFrameAPI} */ get crossFrame() { + if (this._crossFrame === null) { throw new Error('Not prepared'); } return this._crossFrame; } @@ -158,19 +177,22 @@ class Yomitan extends EventDispatcher { /** * Runs `chrome.runtime.sendMessage()` with additional exception handling events. - * @param {...*} args The arguments to be passed to `chrome.runtime.sendMessage()`. - * @returns {void} The result of the `chrome.runtime.sendMessage()` call. + * @param {import('extension').ChromeRuntimeSendMessageArgs} args The arguments to be passed to `chrome.runtime.sendMessage()`. * @throws {Error} Errors thrown by `chrome.runtime.sendMessage()` are re-thrown. */ sendMessage(...args) { try { - return chrome.runtime.sendMessage(...args); + // @ts-expect-error - issue with type conversion, somewhat difficult to resolve in pure JS + chrome.runtime.sendMessage(...args); } catch (e) { this.triggerExtensionUnloaded(); throw e; } } + /** + * Triggers the extensionUnloaded event. + */ triggerExtensionUnloaded() { this._isExtensionUnloaded = true; if (this._isTriggeringExtensionUnloaded) { return; } @@ -184,51 +206,73 @@ class Yomitan extends EventDispatcher { // Private + /** + * @returns {string} + */ _getUrl() { return location.href; } - _getLogContext() { - return {url: this._getUrl()}; - } - + /** @type {import('extension').ChromeRuntimeOnMessageCallback} */ _onMessage({action, params}, sender, callback) { const messageHandler = this._messageHandlers.get(action); if (typeof messageHandler === 'undefined') { return false; } return invokeMessageHandler(messageHandler, params, callback, sender); } + /** + * @returns {boolean} + */ _onMessageIsReady() { return this._isReady; } + /** + * @returns {void} + */ _onMessageBackendReady() { if (this._isBackendReadyPromiseResolve === null) { return; } this._isBackendReadyPromiseResolve(); this._isBackendReadyPromiseResolve = null; } + /** + * @returns {{url: string}} + */ _onMessageGetUrl() { return {url: this._getUrl()}; } + /** + * @param {{source: string}} params + */ _onMessageOptionsUpdated({source}) { if (source !== 'background') { this.trigger('optionsUpdated', {source}); } } + /** + * @param {{type: string, cause: string}} params + */ _onMessageDatabaseUpdated({type, cause}) { this.trigger('databaseUpdated', {type, cause}); } + /** + * @param {{oldZoomFactor: number, newZoomFactor: number}} params + */ _onMessageZoomChanged({oldZoomFactor, newZoomFactor}) { this.trigger('zoomChanged', {oldZoomFactor, newZoomFactor}); } + /** + * @param {{error: unknown, level: import('log').LogLevel, context?: import('log').LogContext}} params + */ async _onForwardLog({error, level, context}) { try { - await this._api.log(serializeError(error), level, context); + const api = /** @type {API} */ (this._api); + await api.log(ExtensionError.serialize(error), level, context); } catch (e) { // NOP } |