diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2023-11-27 12:48:14 -0500 |
---|---|---|
committer | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2023-11-27 12:48:14 -0500 |
commit | 4da4827bcbcdd1ef163f635d9b29416ff272b0bb (patch) | |
tree | a8a0f1a8befdb78a554e1be91f2c6059ca3ad5f9 /ext/js/pages | |
parent | fd6bba8a2a869eaf2b2c1fa49001f933fce3c618 (diff) |
Add JSDoc type annotations to project (rebased)
Diffstat (limited to 'ext/js/pages')
40 files changed, 3751 insertions, 816 deletions
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..0ccd018d 100644 --- a/ext/js/pages/settings/anki-controller.js +++ b/ext/js/pages/settings/anki-controller.js @@ -24,9 +24,15 @@ import {ObjectPropertyAccessor} from '../../general/object-property-accessor.js' import {yomitan} from '../../yomitan.js'; export class AnkiController { + /** + * @param {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {AnkiConnect} */ this._ankiConnect = new AnkiConnect(); + /** @type {SelectorObserver<AnkiCardController>} */ this._selectorObserver = new SelectorObserver({ selector: '.anki-card', ignoreSelector: null, @@ -34,52 +40,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 {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 +122,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 +186,9 @@ export class AnkiController { } } + /** + * @returns {Promise<import('anki-controller').AnkiData>} + */ async getAnkiData() { let promise = this._getAnkiDataPromise; if (promise === null) { @@ -164,23 +199,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 +239,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 +316,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 +377,9 @@ export class AnkiController { } } + /** + * @returns {Promise<import('anki-controller').AnkiData>} + */ async _getAnkiData() { this._setAnkiStatusChanging(); const [ @@ -305,85 +401,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 +527,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 +543,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 {SettingsController} settingsController + * @param {AnkiController} ankiController + * @param {HTMLElement} node + */ constructor(settingsController, ankiController, node) { + /** @type {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 +606,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 +621,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 +637,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 +713,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 +743,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 +768,7 @@ class AnkiCardController { } } + /** */ _setupFields() { this._fieldEventListeners.removeAllEventListeners(); @@ -563,15 +778,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 +796,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 +817,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 +851,9 @@ class AnkiCardController { } } + /** + * @param {string} value + */ async _setDeck(value) { if (this._deckController.value === value) { return; } this._deckController.value = value; @@ -644,6 +865,9 @@ class AnkiCardController { }]); } + /** + * @param {string} value + */ async _setModel(value) { const select = this._modelController.select; if (this._modelChangingTo !== null) { @@ -671,12 +895,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 +924,9 @@ class AnkiCardController { this._setupFields(); } + /** + * @param {string[]} permissions + */ async _requestPermissions(permissions) { try { await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true); @@ -706,6 +935,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 +959,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 +983,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 +1027,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 +1040,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 +1063,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 +1090,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 +1107,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 +1121,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..d2814880 100644 --- a/ext/js/pages/settings/anki-templates-controller.js +++ b/ext/js/pages/settings/anki-templates-controller.js @@ -16,39 +16,56 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {isObject} from '../../core.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 {SettingsController} settingsController + * @param {ModalController} modalController + * @param {AnkiController} ankiController + */ constructor(settingsController, modalController, ankiController) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {ModalController} */ this._modalController = modalController; + /** @type {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 {?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 +73,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 +150,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 +208,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 +244,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 +257,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 +287,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..480597af 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 {SettingsController} settingsController + * @param {ModalController} modalController + */ constructor(settingsController, modalController) { super(); + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {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 {SettingsController} */ get settingsController() { return this._settingsController; } + /** @type {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..a05d0056 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 {SettingsController} settingsController + * @param {?ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {?ModalController} */ this._modalController = modalController; + /** @type {?import('core').TokenObject} */ this._settingsExportToken = null; + /** @type {?() => void} */ this._settingsExportRevoke = null; + /** @type {number} */ this._currentVersion = 0; + /** @type {?Modal} */ this._settingsResetModal = null; + /** @type {?Modal} */ this._settingsImportErrorModal = null; + /** @type {?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-ignore - call for legacy Edge + typeof navigator.msSaveBlob === 'function' && + // @ts-ignore - 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,6 +533,11 @@ export class BackupController { // Exporting Dictionaries Database + /** + * + * @param message + * @param isWarning + */ _databaseExportImportErrorMessage(message, isWarning=false) { const errorMessageContainer = document.querySelector('#db-ops-error-report'); errorMessageContainer.style.display = 'block'; @@ -439,6 +552,13 @@ export class BackupController { } } + /** + * + * @param root0 + * @param root0.totalRows + * @param root0.completedRows + * @param root0.done + */ _databaseExportProgressCallback({totalRows, completedRows, done}) { console.log(`Progress: ${completedRows} of ${totalRows} rows completed`); const messageContainer = document.querySelector('#db-ops-progress-report'); @@ -451,6 +571,10 @@ export class BackupController { } } + /** + * + * @param databaseName + */ async _exportDatabase(databaseName) { const db = await new Dexie(databaseName).open(); const blob = await db.export({progressCallback: this._databaseExportProgressCallback}); @@ -458,6 +582,9 @@ export class BackupController { return blob; } + /** + * + */ async _onSettingsExportDatabaseClick() { if (this._settingsExportDatabaseToken !== null) { // An existing import or export is in progress. @@ -488,6 +615,13 @@ export class BackupController { // Importing Dictionaries Database + /** + * + * @param root0 + * @param root0.totalRows + * @param root0.completedRows + * @param root0.done + */ _databaseImportProgressCallback({totalRows, completedRows, done}) { console.log(`Progress: ${completedRows} of ${totalRows} rows completed`); const messageContainer = document.querySelector('#db-ops-progress-report'); @@ -502,6 +636,11 @@ export class BackupController { } } + /** + * + * @param databaseName + * @param file + */ async _importDatabase(databaseName, file) { await yomitan.api.purgeDatabase(); await Dexie.import(file, {progressCallback: this._databaseImportProgressCallback}); @@ -509,10 +648,17 @@ export class BackupController { yomitan.trigger('storageChanged'); } + /** + * + */ _onSettingsImportDatabaseClick() { document.querySelector('#settings-import-db').click(); } + /** + * + * @param e + */ async _onSettingsImportDatabaseChange(e) { if (this._settingsExportDatabaseToken !== null) { // An existing import or export is in progress. diff --git a/ext/js/pages/settings/collapsible-dictionary-controller.js b/ext/js/pages/settings/collapsible-dictionary-controller.js index c8ce5e4f..a508bae4 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..85f7493f 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 {SettingsController} settingsController + * @param {ModalController} modalController + * @param {StatusFooter} statusFooter + */ constructor(settingsController, modalController, statusFooter) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {ModalController} */ this._modalController = modalController; + /** @type {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 {?Modal} */ this._deleteDictionaryModal = null; + /** @type {?HTMLInputElement} */ this._allCheckbox = null; + /** @type {?DictionaryExtraInfo} */ this._extraInfo = null; + /** @type {boolean} */ this._isDeleting = false; } + /** @type {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 {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 {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 {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 {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..106ecbca 100644 --- a/ext/js/pages/settings/dictionary-import-controller.js +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -16,25 +16,43 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, log} from '../../core.js'; +import {log} from '../../core.js'; import {DictionaryWorker} from '../../language/dictionary-worker.js'; import {yomitan} from '../../yomitan.js'; import {DictionaryController} from './dictionary-controller.js'; export class DictionaryImportController { + /** + * @param {SettingsController} settingsController + * @param {ModalController} modalController + * @param {StatusFooter} statusFooter + */ constructor(settingsController, modalController, statusFooter) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {ModalController} */ this._modalController = modalController; + /** @type {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 {?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 +65,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 +84,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 {Modal} */ (this._purgeConfirmModal).setVisible(true); } + /** + * @param {MouseEvent} e + */ _onPurgeConfirmButtonClick(e) { e.preventDefault(); - this._purgeConfirmModal.setVisible(false); + /** @type {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 +138,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 +148,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 +178,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 +220,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 +235,10 @@ export class DictionaryImportController { } } + /** + * @param {number} stepIndex + * @returns {string} + */ _getImportLabel(stepIndex) { switch (stepIndex) { case -1: @@ -212,6 +252,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 +270,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 +294,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 +311,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 +356,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..6c9a3864 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..3c6104a9 100644 --- a/ext/js/pages/settings/generic-setting-controller.js +++ b/ext/js/pages/settings/generic-setting-controller.js @@ -16,14 +16,19 @@ * along with this program. If not, see <https://www.gnu.org/licenses/>. */ -import {deserializeError, isObject} from '../../core.js'; import {DocumentUtil} from '../../dom/document-util.js'; import {DOMDataBinder} from '../../dom/dom-data-binder.js'; export class GenericSettingController { + /** + * @param {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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 +36,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 +46,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 +97,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 +175,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 +190,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 +205,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 +223,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 +244,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..2fb1ff8a 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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 {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 {?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 {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..ac078a0c 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..9cad2fb2 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..ed4f7a8c 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..c555f9cf 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..acf4e0de 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 {PopupFactory} popupFactory + * @param {HotkeyHandler} hotkeyHandler + */ constructor(tabId, frameId, popupFactory, hotkeyHandler) { + /** @type {number} */ this._tabId = tabId; + /** @type {number} */ this._frameId = frameId; + /** @type {PopupFactory} */ this._popupFactory = popupFactory; + /** @type {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 {?number} */ 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..5ebd9011 100644 --- a/ext/js/pages/settings/profile-conditions-ui.js +++ b/ext/js/pages/settings/profile-conditions-ui.js @@ -19,21 +19,41 @@ import {EventDispatcher, EventListenerCollection} from '../../core.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; +/* global + * DocumentUtil + * KeyboardMouseInputField + */ + +/** + * @augments EventDispatcher<import('profile-conditions-ui').EventType> + */ export class ProfileConditionsUI extends EventDispatcher { + /** + * @param {SettingsController} settingsController + */ constructor(settingsController) { super(); + /** @type {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 +108,23 @@ export class ProfileConditionsUI extends EventDispatcher { } ] ]); + /** @type {Set<string>} */ this._validFlags = new Set([ 'clipboard' ]); } + /** @type {SettingsController} */ get settingsController() { return this._settingsController; } + /** @type {number} */ get profileIndex() { return this._profileIndex; } + /** @type {?import('environment').OperatingSystem} */ get os() { return this._os; } @@ -109,6 +133,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 +143,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 +153,7 @@ export class ProfileConditionsUI extends EventDispatcher { this._eventListeners.addEventListener(this._addConditionGroupButton, 'click', this._onAddConditionGroupButtonClick.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); @@ -138,10 +166,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 +185,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 +200,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 +243,9 @@ export class ProfileConditionsUI extends EventDispatcher { }; } + /** + * @returns {import('settings').ProfileCondition} + */ getDefaultCondition() { const type = this.getDefaultType(); const operator = this.getDefaultOperator(type); @@ -199,6 +253,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 +284,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 +349,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 +402,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 +433,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 {SettingsController} */ get settingsController() { return this._parent.settingsController; } + /** @type {ProfileConditionsUI} */ get parent() { return this._parent; } + /** @type {number} */ get index() { return this._index; } @@ -346,19 +492,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 +514,7 @@ class ProfileConditionGroupUI { this._eventListeners.addEventListener(this._addConditionButton, 'click', this._onAddConditionButtonClick.bind(this), false); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); @@ -378,15 +526,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 +564,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 +596,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 {SettingsController} */ get settingsController() { return this._parent.parent.settingsController; } + /** @type {ProfileConditionGroupUI} */ get parent() { return this._parent; } + /** @type {number} */ get index() { return this._index; } @@ -485,24 +671,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 +696,7 @@ class ProfileConditionUI { } } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); this._value = ''; @@ -524,17 +704,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 +720,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 +757,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 +771,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 +804,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 +862,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 +875,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 +891,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 +899,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 +918,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 +995,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 +1013,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..a74a7567 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 {SettingsController} settingsController + * @param {ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {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 {?Modal} */ this._profileRemoveModal = null; + /** @type {?Modal} */ this._profileCopyModal = null; + /** @type {?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 {Modal} */ (this._profileRemoveModal).node.dataset.profileIndex = `${profileIndex}`; + /** @type {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 {Modal} */ (this._profileCopyModal).node.dataset.profileIndex = `${profileIndex}`; + /** @type {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 {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 {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/scan-inputs-controller.js b/ext/js/pages/settings/scan-inputs-controller.js index 252e7238..f294050b 100644 --- a/ext/js/pages/settings/scan-inputs-controller.js +++ b/ext/js/pages/settings/scan-inputs-controller.js @@ -20,23 +20,38 @@ import {EventListenerCollection} from '../../core.js'; import {yomitan} from '../../yomitan.js'; import {KeyboardMouseInputField} from './keyboard-mouse-input-field.js'; +/* global + * DocumentUtil + * KeyboardMouseInputField + */ + export class ScanInputsController { + /** + * @param {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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 +60,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 +83,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 +97,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 +140,9 @@ export class ScanInputsController { this._updateCounts(); } + /** + * @param {MouseEvent} e + */ _onAddButtonClick(e) { e.preventDefault(); @@ -119,34 +159,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 +229,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 +261,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 +300,7 @@ class ScanInputField { this._updateDataSettingTargets(); } + /** */ cleanup() { this._eventListeners.removeAllEventListeners(); if (this._includeInputField !== null) { @@ -241,26 +316,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 +356,9 @@ class ScanInputField { } } + /** + * @param {import('popup-menu').MenuCloseEvent} e + */ _onMenuClose(e) { switch (e.detail.action) { case 'remove': @@ -281,40 +371,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..1e422c5b 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..7c2d3907 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..3edabb67 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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 {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..bcbd6a44 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 {SettingsController} settingsController + * @param {ModalController} modalController + */ constructor(settingsController, modalController) { + /** @type {SettingsController} */ this._settingsController = settingsController; + /** @type {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..5c5841b1 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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..8af44b33 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 {PersistentStorageController} persistentStorageController + */ constructor(persistentStorageController) { + /** @type {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..690ccfe8 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 {SettingsController} settingsController + */ constructor(settingsController) { + /** @type {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(); |