diff options
Diffstat (limited to 'ext/js/pages/settings')
34 files changed, 8255 insertions, 0 deletions
diff --git a/ext/js/pages/settings/anki-controller.js b/ext/js/pages/settings/anki-controller.js new file mode 100644 index 00000000..26cab68f --- /dev/null +++ b/ext/js/pages/settings/anki-controller.js @@ -0,0 +1,729 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiConnect + * AnkiNoteBuilder + * ObjectPropertyAccessor + * SelectorObserver + */ + +class AnkiController { + constructor(settingsController) { + this._settingsController = settingsController; + this._ankiConnect = new AnkiConnect(); + this._ankiNoteBuilder = new AnkiNoteBuilder(false); + this._selectorObserver = new SelectorObserver({ + selector: '.anki-card', + ignoreSelector: null, + onAdded: this._createCardController.bind(this), + onRemoved: this._removeCardController.bind(this), + isStale: this._isCardControllerStale.bind(this) + }); + this._stringComparer = new Intl.Collator(); // Locale does not matter + this._getAnkiDataPromise = null; + this._ankiErrorContainer = null; + this._ankiErrorMessageNode = null; + this._ankiErrorMessageNodeDefaultContent = ''; + this._ankiErrorMessageDetailsNode = null; + this._ankiErrorMessageDetailsContainer = null; + this._ankiErrorMessageDetailsToggle = null; + this._ankiErrorInvalidResponseInfo = null; + this._ankiCardPrimary = null; + this._ankiCardPrimaryType = null; + this._validateFieldsToken = null; + } + + 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'); + this._ankiCardPrimaryType = document.querySelector('#anki-card-primary-type'); + + 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._ankiCardPrimaryType !== null) { this._ankiCardPrimaryType.addEventListener('change', this._onAnkiCardPrimaryTypeChange.bind(this), false); } + + const options = await this._settingsController.getOptions(); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + this._onOptionsChanged({options}); + } + + getFieldMarkers(type) { + switch (type) { + case 'terms': + return [ + 'audio', + 'clipboard-image', + 'clipboard-text', + 'cloze-body', + 'cloze-prefix', + 'cloze-suffix', + 'conjugation', + 'dictionary', + 'document-title', + 'expression', + 'frequencies', + 'furigana', + 'furigana-plain', + 'glossary', + 'glossary-brief', + 'glossary-no-dictionary', + 'pitch-accents', + 'pitch-accent-graphs', + 'pitch-accent-positions', + 'reading', + 'screenshot', + 'sentence', + 'tags', + 'url' + ]; + case 'kanji': + return [ + 'character', + 'clipboard-image', + 'clipboard-text', + 'cloze-body', + 'cloze-prefix', + 'cloze-suffix', + 'dictionary', + 'document-title', + 'glossary', + 'kunyomi', + 'onyomi', + 'screenshot', + 'sentence', + 'stroke-count', + 'tags', + 'url' + ]; + default: + return []; + } + } + + getFieldMarkersHtml(markers) { + const fragment = document.createDocumentFragment(); + for (const marker of markers) { + const markerNode = this._settingsController.instantiateTemplate('anki-card-field-marker'); + markerNode.querySelector('.marker-link').textContent = marker; + fragment.appendChild(markerNode); + } + return fragment; + } + + async getAnkiData() { + let promise = this._getAnkiDataPromise; + if (promise === null) { + promise = this._getAnkiData(); + this._getAnkiDataPromise = promise; + promise.finally(() => { this._getAnkiDataPromise = null; }); + } + return promise; + } + + async getModelFieldNames(model) { + return await this._ankiConnect.getModelFieldNames(model); + } + + getRequiredPermissions(fieldValue) { + return this._settingsController.permissionsUtil.getRequiredPermissionsForAnkiFieldValue(fieldValue); + } + + containsAnyMarker(field) { + return this._ankiNoteBuilder.containsAnyMarker(field); + } + + // Private + + async _onOptionsChanged({options: {anki}}) { + this._ankiConnect.server = anki.server; + this._ankiConnect.enabled = anki.enable; + + this._selectorObserver.disconnect(); + this._selectorObserver.observe(document.documentElement, true); + } + + _onAnkiErrorMessageDetailsToggleClick() { + const node = this._ankiErrorMessageDetailsContainer; + node.hidden = !node.hidden; + } + + _onAnkiEnableChanged({detail: {value}}) { + if (this._ankiConnect.server === null) { return; } + this._ankiConnect.enabled = value; + + for (const cardController of this._selectorObserver.datas()) { + cardController.updateAnkiState(); + } + } + + _onAnkiCardPrimaryTypeChange(e) { + if (this._ankiCardPrimary === null) { return; } + const node = e.currentTarget; + let ankiCardMenu; + if (node.selectedIndex >= 0) { + const option = node.options[node.selectedIndex]; + ankiCardMenu = option.dataset.ankiCardMenu; + } + + this._ankiCardPrimary.dataset.ankiCardType = node.value; + if (typeof ankiCardMenu !== 'undefined') { + this._ankiCardPrimary.dataset.ankiCardMenu = ankiCardMenu; + } else { + delete this._ankiCardPrimary.dataset.ankiCardMenu; + } + } + + _createCardController(node) { + const cardController = new AnkiCardController(this._settingsController, this, node); + cardController.prepare(); + return cardController; + } + + _removeCardController(node, cardController) { + cardController.cleanup(); + } + + _isCardControllerStale(node, cardController) { + return cardController.isStale(); + } + + _setupFieldMenus() { + 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); + if (element === null) { continue; } + + let markers = []; + for (const type of types) { + markers.push(...this.getFieldMarkers(type)); + } + markers = [...new Set(markers)]; + + const container = element.content.querySelector('.popup-menu-body'); + if (container === null) { return; } + + const fragment = document.createDocumentFragment(); + for (const marker of markers) { + const option = document.createElement('button'); + option.textContent = marker; + option.className = 'popup-menu-item'; + option.dataset.menuAction = 'setFieldMarker'; + option.dataset.marker = marker; + fragment.appendChild(option); + } + container.appendChild(fragment); + } + } + + async _getAnkiData() { + this._setAnkiStatusChanging(); + const [ + [deckNames, error1], + [modelNames, error2] + ] = await Promise.all([ + this._getDeckNames(), + this._getModelNames() + ]); + + if (error1 !== null) { + this._showAnkiError(error1); + } else if (error2 !== null) { + this._showAnkiError(error2); + } else { + this._hideAnkiError(); + } + + return {deckNames, modelNames}; + } + + async _getDeckNames() { + try { + const result = await this._ankiConnect.getDeckNames(); + this._sortStringArray(result); + return [result, null]; + } catch (e) { + return [[], e]; + } + } + + async _getModelNames() { + try { + const result = await this._ankiConnect.getModelNames(); + this._sortStringArray(result); + return [result, null]; + } catch (e) { + return [[], e]; + } + } + + _setAnkiStatusChanging() { + this._ankiErrorMessageNode.textContent = this._ankiErrorMessageNodeDefaultContent; + this._ankiErrorMessageNode.classList.remove('danger-text'); + } + + _hideAnkiError() { + 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 = ''; + } + + _showAnkiError(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'); + + const data = error.data; + let details = ''; + if (typeof data !== 'undefined') { + details += `${JSON.stringify(data, null, 4)}\n\n`; + } + details += `${error.stack}`.trimRight(); + 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; + } + + _sortStringArray(array) { + const stringComparer = this._stringComparer; + array.sort((a, b) => stringComparer.compare(a, b)); + } +} + +class AnkiCardController { + constructor(settingsController, ankiController, node) { + this._settingsController = settingsController; + this._ankiController = ankiController; + this._node = node; + this._cardType = node.dataset.ankiCardType; + this._cardMenu = node.dataset.ankiCardMenu; + this._eventListeners = new EventListenerCollection(); + this._fieldEventListeners = new EventListenerCollection(); + this._deck = null; + this._model = null; + this._fields = null; + this._modelChangingTo = null; + this._ankiCardDeckSelect = null; + this._ankiCardModelSelect = null; + this._ankiCardFieldsContainer = null; + this._cleaned = false; + this._fieldEntries = []; + } + + async prepare() { + const options = await this._settingsController.getOptions(); + const ankiOptions = options.anki; + if (this._cleaned) { return; } + + const cardOptions = this._getCardOptions(ankiOptions, this._cardType); + if (cardOptions === null) { return; } + const {deck, model, fields} = cardOptions; + this._deck = deck; + this._model = model; + this._fields = fields; + + this._ankiCardDeckSelect = this._node.querySelector('.anki-card-deck'); + this._ankiCardModelSelect = this._node.querySelector('.anki-card-model'); + this._ankiCardFieldsContainer = this._node.querySelector('.anki-card-fields'); + + this._setupSelects([], []); + this._setupFields(); + + this._eventListeners.addEventListener(this._ankiCardDeckSelect, 'change', this._onCardDeckChange.bind(this), false); + this._eventListeners.addEventListener(this._ankiCardModelSelect, 'change', this._onCardModelChange.bind(this), false); + this._eventListeners.on(this._settingsController, 'permissionsChanged', this._onPermissionsChanged.bind(this)); + + 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(); + if (this._cleaned) { return; } + this._setupSelects(deckNames, modelNames); + } + + isStale() { + return (this._cardType !== this._node.dataset.ankiCardType); + } + + // Private + + _onCardDeckChange(e) { + this._setDeck(e.currentTarget.value); + } + + _onCardModelChange(e) { + this._setModel(e.currentTarget.value); + } + + _onFieldChange(index, e) { + const node = e.currentTarget; + this._validateFieldPermissions(node, index, true); + this._validateField(node, index); + } + + _onFieldInput(index, e) { + const node = e.currentTarget; + this._validateField(node, index); + } + + _onFieldSettingChanged(index, e) { + const node = e.currentTarget; + this._validateFieldPermissions(node, index, false); + } + + _onFieldMenuClose({currentTarget: button, detail: {action, item}}) { + switch (action) { + case 'setFieldMarker': + this._setFieldMarker(button, item.dataset.marker); + break; + } + } + + _onFieldMarkerLinkClick(e) { + e.preventDefault(); + const link = e.currentTarget; + this._setFieldMarker(link, link.textContent); + } + + _validateField(node, index) { + let valid = (node.dataset.hasPermissions !== 'false'); + if (valid && index === 0 && !this._ankiController.containsAnyMarker(node.value)) { + valid = false; + } + node.dataset.invalid = `${!valid}`; + } + + _setFieldMarker(element, marker) { + const input = element.closest('.anki-card-field-value-container').querySelector('.anki-card-field-value'); + input.value = `{${marker}}`; + input.dispatchEvent(new Event('change')); + } + + _getCardOptions(ankiOptions, cardType) { + switch (cardType) { + case 'terms': return ankiOptions.terms; + case 'kanji': return ankiOptions.kanji; + default: return null; + } + } + + _setupSelects(deckNames, modelNames) { + const deck = this._deck; + const model = this._model; + if (!deckNames.includes(deck)) { deckNames = [...deckNames, deck]; } + if (!modelNames.includes(model)) { modelNames = [...modelNames, model]; } + + this._setSelectOptions(this._ankiCardDeckSelect, deckNames); + this._ankiCardDeckSelect.value = deck; + + this._setSelectOptions(this._ankiCardModelSelect, modelNames); + this._ankiCardModelSelect.value = model; + } + + _setSelectOptions(select, optionValues) { + const fragment = document.createDocumentFragment(); + for (const optionValue of optionValues) { + const option = document.createElement('option'); + option.value = optionValue; + option.textContent = optionValue; + fragment.appendChild(option); + } + select.textContent = ''; + select.appendChild(fragment); + } + + _setupFields() { + this._fieldEventListeners.removeAllEventListeners(); + + const markers = this._ankiController.getFieldMarkers(this._cardType); + const totalFragment = document.createDocumentFragment(); + this._fieldEntries = []; + let index = 0; + 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'); + fieldNameContainerNode.dataset.index = `${index}`; + const fieldNameNode = content.querySelector('.anki-card-field-name'); + fieldNameNode.textContent = fieldName; + + const valueContainer = content.querySelector('.anki-card-field-value-container'); + valueContainer.dataset.index = `${index}`; + + const inputField = content.querySelector('.anki-card-field-value'); + inputField.value = fieldValue; + inputField.dataset.setting = ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields', fieldName]); + this._validateFieldPermissions(inputField, index, false); + + this._fieldEventListeners.addEventListener(inputField, 'change', this._onFieldChange.bind(this, index), false); + this._fieldEventListeners.addEventListener(inputField, 'input', this._onFieldInput.bind(this, index), false); + this._fieldEventListeners.addEventListener(inputField, 'settingChanged', this._onFieldSettingChanged.bind(this, index), false); + this._validateField(inputField, index); + + const markerList = content.querySelector('.anki-card-field-marker-list'); + if (markerList !== null) { + const markersFragment = this._ankiController.getFieldMarkersHtml(markers); + for (const element of markersFragment.querySelectorAll('.marker-link')) { + this._fieldEventListeners.addEventListener(element, 'click', this._onFieldMarkerLinkClick.bind(this), false); + } + markerList.appendChild(markersFragment); + } + + const menuButton = content.querySelector('.anki-card-field-value-menu-button'); + if (menuButton !== null) { + if (typeof this._cardMenu !== 'undefined') { + menuButton.dataset.menu = this._cardMenu; + } else { + delete menuButton.dataset.menu; + } + this._fieldEventListeners.addEventListener(menuButton, 'menuClose', this._onFieldMenuClose.bind(this), false); + } + + totalFragment.appendChild(content); + this._fieldEntries.push({fieldName, inputField, fieldNameContainerNode}); + + ++index; + } + + 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); + } + container.appendChild(totalFragment); + + this._validateFields(); + } + + async _validateFields() { + const token = {}; + this._validateFieldsToken = token; + + let fieldNames; + try { + fieldNames = await this._ankiController.getModelFieldNames(this._model); + } catch (e) { + return; + } + + if (token !== this._validateFieldsToken) { return; } + + const fieldNamesSet = new Set(fieldNames); + let index = 0; + for (const {fieldName, fieldNameContainerNode} of this._fieldEntries) { + fieldNameContainerNode.dataset.invalid = `${!fieldNamesSet.has(fieldName)}`; + fieldNameContainerNode.dataset.orderMatches = `${index < fieldNames.length && fieldName === fieldNames[index]}`; + ++index; + } + } + + async _setDeck(value) { + if (this._deck === value) { return; } + this._deck = value; + + await this._settingsController.modifyProfileSettings([{ + action: 'set', + path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'deck']), + value + }]); + } + + async _setModel(value) { + if (this._modelChangingTo !== null) { + // Revert + this._ankiCardModelSelect.value = this._modelChangingTo; + return; + } + if (this._model === value) { return; } + + let fieldNames; + let options; + try { + this._modelChangingTo = value; + fieldNames = await this._ankiController.getModelFieldNames(value); + options = await this._ankiController.settingsController.getOptions(); + } catch (e) { + // Revert + this._ankiCardModelSelect.value = this._model; + return; + } finally { + this._modelChangingTo = null; + } + + const cardType = this._cardType; + const cardOptions = this._getCardOptions(options.anki, cardType); + const oldFields = cardOptions !== null ? cardOptions.fields : null; + + const fields = {}; + for (let i = 0, ii = fieldNames.length; i < ii; ++i) { + const fieldName = fieldNames[i]; + fields[fieldName] = this._getDefaultFieldValue(fieldName, i, cardType, oldFields); + } + + const targets = [ + { + action: 'set', + path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'model']), + value + }, + { + action: 'set', + path: ObjectPropertyAccessor.getPathString(['anki', this._cardType, 'fields']), + value: fields + } + ]; + + this._model = value; + this._fields = fields; + + await this._settingsController.modifyProfileSettings(targets); + + this._setupFields(); + } + + async _requestPermissions(permissions) { + try { + await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true); + } catch (e) { + log.error(e); + } + } + + async _validateFieldPermissions(node, index, request) { + const fieldValue = node.value; + const permissions = this._ankiController.getRequiredPermissions(fieldValue); + if (permissions.length > 0) { + node.dataset.requiredPermission = permissions.join(' '); + const hasPermissions = await ( + request ? + this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, true) : + this._settingsController.permissionsUtil.hasPermissions({permissions}) + ); + node.dataset.hasPermissions = `${hasPermissions}`; + } else { + delete node.dataset.requiredPermission; + delete node.dataset.hasPermissions; + } + + this._validateField(node, index); + } + + _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; + if (typeof requiredPermission !== 'string') { continue; } + requiredPermission = (requiredPermission.length === 0 ? [] : requiredPermission.split(' ')); + + let hasPermissions = true; + for (const permission of requiredPermission) { + if (!permissionsSet.has(permission)) { + hasPermissions = false; + break; + } + } + + inputField.dataset.hasPermissions = `${hasPermissions}`; + this._validateField(inputField, i); + } + } + + _getDefaultFieldValue(fieldName, index, cardType, oldFields) { + if ( + typeof oldFields === 'object' && + oldFields !== null && + Object.prototype.hasOwnProperty.call(oldFields, fieldName) + ) { + return oldFields[fieldName]; + } + + if (index === 0) { + return (cardType === 'kanji' ? '{character}' : '{expression}'); + } + + const markers = this._ankiController.getFieldMarkers(cardType); + const markerAliases = new Map([ + ['glossary', ['definition', 'meaning']], + ['audio', ['sound']], + ['dictionary', ['dict']] + ]); + + const hyphenPattern = /-/g; + for (const marker of markers) { + const names = [marker]; + const aliases = markerAliases.get(marker); + if (typeof aliases !== 'undefined') { + names.push(...aliases); + } + + let pattern = '^(?:'; + for (let i = 0, ii = names.length; i < ii; ++i) { + const name = names[i]; + if (i > 0) { pattern += '|'; } + pattern += name.replace(hyphenPattern, '[-_ ]*'); + } + pattern += ')$'; + pattern = new RegExp(pattern, 'i'); + + if (pattern.test(fieldName)) { + return `{${marker}}`; + } + } + + return ''; + } +} diff --git a/ext/js/pages/settings/anki-templates-controller.js b/ext/js/pages/settings/anki-templates-controller.js new file mode 100644 index 00000000..8e3a1a70 --- /dev/null +++ b/ext/js/pages/settings/anki-templates-controller.js @@ -0,0 +1,227 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiNoteBuilder + */ + +class AnkiTemplatesController { + constructor(settingsController, modalController, ankiController) { + this._settingsController = settingsController; + this._modalController = modalController; + this._ankiController = ankiController; + this._cachedDefinitionValue = null; + this._cachedDefinitionText = null; + this._defaultFieldTemplates = null; + this._fieldTemplatesTextarea = null; + this._compileResultInfo = null; + this._renderFieldInput = null; + this._renderResult = null; + this._fieldTemplateResetModal = null; + this._ankiNoteBuilder = new AnkiNoteBuilder(true); + } + + async prepare() { + this._defaultFieldTemplates = await yomichan.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'); + const fieldList = document.querySelector('#anki-card-templates-field-list'); + this._fieldTemplateResetModal = this._modalController.getModal('anki-card-templates-reset'); + + const markers = new Set([ + ...this._ankiController.getFieldMarkers('terms'), + ...this._ankiController.getFieldMarkers('kanji') + ]); + + if (fieldList !== null) { + const fragment = this._ankiController.getFieldMarkersHtml(markers); + fieldList.appendChild(fragment); + for (const node of fieldList.querySelectorAll('.marker-link')) { + node.addEventListener('click', this._onMarkerClicked.bind(this), false); + } + } + + this._fieldTemplatesTextarea.addEventListener('change', this._onChanged.bind(this), false); + testRenderButton.addEventListener('click', this._onRender.bind(this), false); + 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); + } + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onOptionsChanged({options}) { + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + this._fieldTemplatesTextarea.value = templates; + + this._onValidateCompile(); + } + + _onReset(e) { + e.preventDefault(); + this._fieldTemplateResetModal.setVisible(true); + } + + _onResetConfirm(e) { + e.preventDefault(); + + this._fieldTemplateResetModal.setVisible(false); + + const value = this._defaultFieldTemplates; + + this._fieldTemplatesTextarea.value = value; + this._fieldTemplatesTextarea.dispatchEvent(new Event('change')); + } + + async _onChanged(e) { + // Get value + let templates = e.currentTarget.value; + if (templates === this._defaultFieldTemplates) { + // Default + templates = null; + } + + // Overwrite + await this._settingsController.setProfileSetting('anki.fieldTemplates', templates); + + // Compile + this._onValidateCompile(); + } + + _onValidateCompile() { + this._validate(this._compileResultInfo, '{expression}', 'term-kanji', false, true); + } + + _onMarkerClicked(e) { + e.preventDefault(); + this._renderFieldInput.value = `{${e.target.textContent}}`; + } + + _onRender(e) { + e.preventDefault(); + + const field = this._renderFieldInput.value; + const infoNode = this._renderResult; + infoNode.hidden = true; + this._cachedDefinitionText = null; + this._validate(infoNode, field, 'term-kanji', true, false); + } + + _onFieldMenuClose({currentTarget: button, detail: {action, item}}) { + switch (action) { + case 'setFieldMarker': + this._setFieldMarker(button, item.dataset.marker); + break; + } + } + + _setFieldMarker(element, marker) { + const input = this._renderFieldInput; + input.value = `{${marker}}`; + input.dispatchEvent(new Event('change')); + } + + async _getDefinition(text, optionsContext) { + if (this._cachedDefinitionText !== text) { + const {definitions} = await yomichan.api.termsFind(text, {}, optionsContext); + if (definitions.length === 0) { return null; } + + this._cachedDefinitionValue = definitions[0]; + this._cachedDefinitionText = text; + } + return this._cachedDefinitionValue; + } + + async _validate(infoNode, field, mode, showSuccessResult, invalidateInput) { + const text = this._renderTextInput.value || ''; + const errors = []; + let result = `No definition found for ${text}`; + try { + const optionsContext = this._settingsController.getOptionsContext(); + const definition = await this._getDefinition(text, optionsContext); + if (definition !== null) { + const options = await this._settingsController.getOptions(); + const context = { + url: window.location.href, + sentence: {text: definition.rawSource, offset: 0}, + documentTitle: document.title + }; + let templates = options.anki.fieldTemplates; + if (typeof templates !== 'string') { templates = this._defaultFieldTemplates; } + const {general: {resultOutputMode, glossaryLayoutMode, compactTags}} = options; + const note = await this._ankiNoteBuilder.createNote({ + definition, + mode, + context, + templates, + deckName: '', + modelName: '', + fields: [ + ['field', field] + ], + resultOutputMode, + glossaryLayoutMode, + compactTags, + errors + }); + result = note.fields.field; + } + } catch (e) { + errors.push(e); + } + + const errorToMessageString = (e) => { + if (isObject(e)) { + let v = e.data; + if (isObject(v)) { + v = v.error; + if (isObject(v)) { + e = v; + } + } + + v = e.message; + if (typeof v === 'string') { return v; } + } + return `${e}`; + }; + + const hasError = errors.length > 0; + infoNode.hidden = !(showSuccessResult || hasError); + infoNode.textContent = hasError ? errors.map(errorToMessageString).join('\n') : (showSuccessResult ? result : ''); + infoNode.classList.toggle('text-danger', hasError); + if (invalidateInput) { + this._fieldTemplatesTextarea.dataset.invalid = `${hasError}`; + } + } +} diff --git a/ext/js/pages/settings/audio-controller.js b/ext/js/pages/settings/audio-controller.js new file mode 100644 index 00000000..e62383a8 --- /dev/null +++ b/ext/js/pages/settings/audio-controller.js @@ -0,0 +1,234 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AudioSystem + */ + +class AudioController { + constructor(settingsController) { + this._settingsController = settingsController; + this._audioSystem = new AudioSystem(); + this._audioSourceContainer = null; + this._audioSourceAddButton = null; + this._audioSourceEntries = []; + this._ttsVoiceTestTextInput = null; + } + + async prepare() { + this._audioSystem.prepare(); + + this._ttsVoiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); + this._audioSourceContainer = document.querySelector('#audio-source-list'); + this._audioSourceAddButton = document.querySelector('#audio-source-add'); + this._audioSourceContainer.textContent = ''; + + this._audioSourceAddButton.addEventListener('click', this._onAddAudioSource.bind(this), false); + + if (typeof speechSynthesis !== 'undefined') { + speechSynthesis.addEventListener('voiceschanged', this._updateTextToSpeechVoices.bind(this), false); + } + this._updateTextToSpeechVoices(); + + document.querySelector('#text-to-speech-voice-test').addEventListener('click', this._onTestTextToSpeech.bind(this), false); + + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onOptionsChanged({options}) { + for (let i = this._audioSourceEntries.length - 1; i >= 0; --i) { + this._cleanupAudioSourceEntry(i); + } + + for (const audioSource of options.audio.sources) { + this._createAudioSourceEntry(audioSource); + } + } + + _onTestTextToSpeech() { + try { + const text = this._ttsVoiceTestTextInput.value || ''; + const voiceUri = document.querySelector('[data-setting="audio.textToSpeechVoice"]').value; + + const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); + audio.volume = 1.0; + audio.play(); + } catch (e) { + // NOP + } + } + + _updateTextToSpeechVoices() { + const voices = ( + typeof speechSynthesis !== 'undefined' ? + [...speechSynthesis.getVoices()].map((voice, index) => ({ + voice, + isJapanese: this._languageTagIsJapanese(voice.lang), + index + })) : + [] + ); + voices.sort(this._textToSpeechVoiceCompare.bind(this)); + + for (const select of document.querySelectorAll('[data-setting="audio.textToSpeechVoice"]')) { + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; + fragment.appendChild(option); + + for (const {voice} of voices) { + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); + } + + select.textContent = ''; + select.appendChild(fragment); + } + } + + _textToSpeechVoiceCompare(a, b) { + if (a.isJapanese) { + if (!b.isJapanese) { return -1; } + } else { + if (b.isJapanese) { return 1; } + } + + if (a.voice.default) { + if (!b.voice.default) { return -1; } + } else { + if (b.voice.default) { return 1; } + } + + return a.index - b.index; + } + + _languageTagIsJapanese(languageTag) { + return ( + languageTag.startsWith('ja_') || + languageTag.startsWith('ja-') || + languageTag.startsWith('jpn-') + ); + } + + _getUnusedAudioSource() { + const audioSourcesAvailable = [ + 'jpod101', + 'jpod101-alternate', + 'jisho', + 'custom' + ]; + for (const source of audioSourcesAvailable) { + if (!this._audioSourceEntries.some((metadata) => metadata.value === source)) { + return source; + } + } + return audioSourcesAvailable[0]; + } + + _createAudioSourceEntry(value) { + const eventListeners = new EventListenerCollection(); + const container = this._settingsController.instantiateTemplate('audio-source'); + const select = container.querySelector('.audio-source-select'); + const removeButton = container.querySelector('.audio-source-remove'); + const menuButton = container.querySelector('.audio-source-menu-button'); + + select.value = value; + + const entry = { + container, + eventListeners, + value + }; + + eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this, entry), false); + if (removeButton !== null) { + eventListeners.addEventListener(removeButton, 'click', this._onAudioSourceRemoveClicked.bind(this, entry), false); + } + if (menuButton !== null) { + eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this, entry), false); + } + + this._audioSourceContainer.appendChild(container); + this._audioSourceEntries.push(entry); + } + + async _removeAudioSourceEntry(entry) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + this._cleanupAudioSourceEntry(index); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 1, + items: [] + }]); + } + + _cleanupAudioSourceEntry(index) { + const {container, eventListeners} = this._audioSourceEntries[index]; + if (container.parentNode !== null) { + container.parentNode.removeChild(container); + } + eventListeners.removeAllEventListeners(); + this._audioSourceEntries.splice(index, 1); + } + + async _onAddAudioSource() { + const audioSource = this._getUnusedAudioSource(); + const index = this._audioSourceEntries.length; + this._createAudioSourceEntry(audioSource); + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'audio.sources', + start: index, + deleteCount: 0, + items: [audioSource] + }]); + } + + async _onAudioSourceSelectChange(entry, event) { + const index = this._audioSourceEntries.indexOf(entry); + if (index < 0) { return; } + + const value = event.currentTarget.value; + entry.value = value; + await this._settingsController.setProfileSetting(`audio.sources[${index}]`, value); + } + + _onAudioSourceRemoveClicked(entry) { + this._removeAudioSourceEntry(entry); + } + + _onMenuClose(entry, e) { + switch (e.detail.action) { + case 'remove': + this._removeAudioSourceEntry(entry); + break; + } + } +} diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js new file mode 100644 index 00000000..649645d4 --- /dev/null +++ b/ext/js/pages/settings/backup-controller.js @@ -0,0 +1,418 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DictionaryController + * OptionsUtil + */ + +class BackupController { + constructor(settingsController, modalController) { + this._settingsController = settingsController; + this._modalController = modalController; + this._settingsExportToken = null; + this._settingsExportRevoke = null; + this._currentVersion = 0; + this._settingsResetModal = null; + this._settingsImportErrorModal = null; + this._settingsImportWarningModal = null; + this._optionsUtil = null; + try { + this._optionsUtil = new OptionsUtil(); + } catch (e) { + // NOP + } + } + + async prepare() { + if (this._optionsUtil !== null) { + await this._optionsUtil.prepare(); + } + + if (this._modalController !== null) { + this._settingsResetModal = this._modalController.getModal('settings-reset'); + this._settingsImportErrorModal = this._modalController.getModal('settings-import-error'); + this._settingsImportWarningModal = this._modalController.getModal('settings-import-warning'); + } + + this._addNodeEventListener('#settings-export-button', 'click', this._onSettingsExportClick.bind(this), false); + this._addNodeEventListener('#settings-import-button', 'click', this._onSettingsImportClick.bind(this), false); + this._addNodeEventListener('#settings-import-file', 'change', this._onSettingsImportFileChange.bind(this), false); + this._addNodeEventListener('#settings-reset-button', 'click', this._onSettingsResetClick.bind(this), false); + this._addNodeEventListener('#settings-reset-confirm-button', 'click', this._onSettingsResetConfirmClick.bind(this), false); + } + + // Private + + _addNodeEventListener(selector, ...args) { + const node = document.querySelector(selector); + if (node === null) { return; } + + node.addEventListener(...args); + } + + _getSettingsExportDateString(date, dateSeparator, dateTimeSeparator, timeSeparator, resolution) { + const values = [ + date.getUTCFullYear().toString(), + dateSeparator, + (date.getUTCMonth() + 1).toString().padStart(2, '0'), + dateSeparator, + date.getUTCDate().toString().padStart(2, '0'), + dateTimeSeparator, + date.getUTCHours().toString().padStart(2, '0'), + timeSeparator, + date.getUTCMinutes().toString().padStart(2, '0'), + timeSeparator, + date.getUTCSeconds().toString().padStart(2, '0') + ]; + return values.slice(0, resolution * 2 - 1).join(''); + } + + async _getSettingsExportData(date) { + const optionsFull = await this._settingsController.getOptionsFull(); + const environment = await yomichan.api.getEnvironmentInfo(); + const fieldTemplatesDefault = await yomichan.api.getDefaultAnkiFieldTemplates(); + const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); + + // Format options + for (const {options} of optionsFull.profiles) { + if (options.anki.fieldTemplates === fieldTemplatesDefault || !options.anki.fieldTemplates) { + options.anki.fieldTemplates = null; + } + } + + const data = { + version: this._currentVersion, + date: this._getSettingsExportDateString(date, '-', ' ', ':', 6), + url: chrome.runtime.getURL('/'), + manifest: chrome.runtime.getManifest(), + environment, + userAgent: navigator.userAgent, + permissions, + options: optionsFull + }; + + return data; + } + + _saveBlob(blob, fileName) { + if (typeof navigator === 'object' && typeof navigator.msSaveBlob === 'function') { + if (navigator.msSaveBlob(blob)) { + return; + } + } + + const blobUrl = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = blobUrl; + a.download = fileName; + a.rel = 'noopener'; + a.target = '_blank'; + + const revoke = () => { + URL.revokeObjectURL(blobUrl); + a.href = ''; + this._settingsExportRevoke = null; + }; + this._settingsExportRevoke = revoke; + + a.dispatchEvent(new MouseEvent('click')); + setTimeout(revoke, 60000); + } + + async _onSettingsExportClick() { + if (this._settingsExportRevoke !== null) { + this._settingsExportRevoke(); + this._settingsExportRevoke = null; + } + + const date = new Date(Date.now()); + + const token = {}; + this._settingsExportToken = token; + const data = await this._getSettingsExportData(date); + if (this._settingsExportToken !== token) { + // A new export has been started + return; + } + this._settingsExportToken = null; + + const fileName = `yomichan-settings-${this._getSettingsExportDateString(date, '-', '-', '-', 6)}.json`; + const blob = new Blob([JSON.stringify(data, null, 4)], {type: 'application/json'}); + this._saveBlob(blob, fileName); + } + + _readFileArrayBuffer(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsArrayBuffer(file); + }); + } + + // Importing + + async _settingsImportSetOptionsFull(optionsFull) { + await this._settingsController.setAllSettings(optionsFull); + } + + _showSettingsImportError(error) { + log.error(error); + document.querySelector('#settings-import-error-message').textContent = `${error}`; + this._settingsImportErrorModal.setVisible(true); + } + + async _showSettingsImportWarnings(warnings) { + const modal = this._settingsImportWarningModal; + const buttons = document.querySelectorAll('.settings-import-warning-import-button'); + const messageContainer = document.querySelector('#settings-import-warning-message'); + if (buttons.length === 0 || messageContainer === null) { + return {result: false}; + } + + // Set message + const fragment = document.createDocumentFragment(); + for (const warning of warnings) { + const node = document.createElement('li'); + node.textContent = `${warning}`; + fragment.appendChild(node); + } + messageContainer.textContent = ''; + messageContainer.appendChild(fragment); + + // Show modal + modal.setVisible(true); + + // Wait for modal to close + return new Promise((resolve) => { + const onButtonClick = (e) => { + e.preventDefault(); + complete({ + result: true, + sanitize: e.currentTarget.dataset.importSanitize === 'true' + }); + modal.setVisible(false); + }; + const onModalVisibilityChanged = ({visible}) => { + if (visible) { return; } + complete({result: false}); + }; + + let completed = false; + const complete = (result) => { + if (completed) { return; } + completed = true; + + modal.off('visibilityChanged', onModalVisibilityChanged); + for (const button of buttons) { + button.removeEventListener('click', onButtonClick, false); + } + + resolve(result); + }; + + // Hook events + modal.on('visibilityChanged', onModalVisibilityChanged); + for (const button of buttons) { + button.addEventListener('click', onButtonClick, false); + } + }); + } + + _isLocalhostUrl(urlString) { + try { + const url = new URL(urlString); + switch (url.hostname.toLowerCase()) { + case 'localhost': + case '127.0.0.1': + case '[::1]': + switch (url.protocol.toLowerCase()) { + case 'http:': + case 'https:': + return true; + } + break; + } + } catch (e) { + // NOP + } + return false; + } + + _settingsImportSanitizeProfileOptions(options, dryRun) { + const warnings = []; + + const anki = options.anki; + if (isObject(anki)) { + const fieldTemplates = anki.fieldTemplates; + if (typeof fieldTemplates === 'string') { + warnings.push('anki.fieldTemplates contains a non-default value'); + if (!dryRun) { + anki.fieldTemplates = null; + } + } + const server = anki.server; + if (typeof server === 'string' && server.length > 0 && !this._isLocalhostUrl(server)) { + warnings.push('anki.server uses a non-localhost URL'); + if (!dryRun) { + anki.server = 'http://127.0.0.1:8765'; + } + } + } + + const audio = options.audio; + if (isObject(audio)) { + const customSourceUrl = audio.customSourceUrl; + if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) { + warnings.push('audio.customSourceUrl uses a non-localhost URL'); + if (!dryRun) { + audio.customSourceUrl = ''; + } + } + } + + return warnings; + } + + _settingsImportSanitizeOptions(optionsFull, dryRun) { + const warnings = new Set(); + + const profiles = optionsFull.profiles; + if (Array.isArray(profiles)) { + for (const profile of profiles) { + if (!isObject(profile)) { continue; } + const options = profile.options; + if (!isObject(options)) { continue; } + + const warnings2 = this._settingsImportSanitizeProfileOptions(options, dryRun); + for (const warning of warnings2) { + warnings.add(warning); + } + } + } + + return warnings; + } + + _utf8Decode(arrayBuffer) { + try { + return new TextDecoder('utf-8').decode(arrayBuffer); + } catch (e) { + const binaryString = String.fromCharCode.apply(null, new Uint8Array(arrayBuffer)); + return decodeURIComponent(escape(binaryString)); + } + } + + async _importSettingsFile(file) { + const dataString = this._utf8Decode(await this._readFileArrayBuffer(file)); + const data = JSON.parse(dataString); + + // Type check + if (!isObject(data)) { + throw new Error(`Invalid data type: ${typeof data}`); + } + + // Version check + const version = data.version; + if (!( + typeof version === 'number' && + Number.isFinite(version) && + version === Math.floor(version) + )) { + throw new Error(`Invalid version: ${version}`); + } + + if (!( + version >= 0 && + version <= this._currentVersion + )) { + throw new Error(`Unsupported version: ${version}`); + } + + // Verify options exists + let optionsFull = data.options; + if (!isObject(optionsFull)) { + throw new Error(`Invalid options type: ${typeof optionsFull}`); + } + + // Upgrade options + optionsFull = await this._optionsUtil.update(optionsFull); + + // Check for warnings + const sanitizationWarnings = this._settingsImportSanitizeOptions(optionsFull, true); + + // Show sanitization warnings + if (sanitizationWarnings.size > 0) { + const {result, sanitize} = await this._showSettingsImportWarnings(sanitizationWarnings); + if (!result) { return; } + + if (sanitize !== false) { + this._settingsImportSanitizeOptions(optionsFull, false); + } + } + + // Assign options + await this._settingsImportSetOptionsFull(optionsFull); + } + + _onSettingsImportClick() { + document.querySelector('#settings-import-file').click(); + } + + async _onSettingsImportFileChange(e) { + const files = e.target.files; + if (files.length === 0) { return; } + + const file = files[0]; + e.target.value = null; + try { + await this._importSettingsFile(file); + } catch (error) { + this._showSettingsImportError(error); + } + } + + // Resetting + + _onSettingsResetClick() { + this._settingsResetModal.setVisible(true); + } + + async _onSettingsResetConfirmClick() { + this._settingsResetModal.setVisible(false); + + // Get default options + const optionsFull = this._optionsUtil.getDefault(); + + // Update dictionaries + const dictionaries = await this._settingsController.getDictionaryInfo(); + for (const {options: {dictionaries: optionsDictionaries}} of optionsFull.profiles) { + for (const {title} of dictionaries) { + optionsDictionaries[title] = DictionaryController.createDefaultDictionarySettings(); + } + } + + // Assign options + try { + await this._settingsImportSetOptionsFull(optionsFull); + } catch (e) { + log.error(e); + } + } +} diff --git a/ext/js/pages/settings/dictionary-controller.js b/ext/js/pages/settings/dictionary-controller.js new file mode 100644 index 00000000..e12017f2 --- /dev/null +++ b/ext/js/pages/settings/dictionary-controller.js @@ -0,0 +1,557 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DictionaryDatabase + * ObjectPropertyAccessor + */ + +class DictionaryEntry { + constructor(dictionaryController, node, dictionaryInfo) { + this._dictionaryController = dictionaryController; + this._node = node; + this._dictionaryInfo = dictionaryInfo; + this._eventListeners = new EventListenerCollection(); + this._detailsContainer = null; + this._hasDetails = false; + this._hasCounts = false; + } + + get node() { + return this._node; + } + + get dictionaryTitle() { + return this._dictionaryInfo.title; + } + + prepare() { + const node = this._node; + const {title, revision, prefixWildcardsSupported, version} = this._dictionaryInfo; + + this._detailsContainer = node.querySelector('.dictionary-details'); + + const enabledCheckbox = node.querySelector('.dictionary-enabled'); + const allowSecondarySearchesCheckbox = node.querySelector('.dictionary-allow-secondary-searches'); + const priorityInput = node.querySelector('.dictionary-priority'); + const deleteButton = node.querySelector('.dictionary-delete-button'); + const menuButton = node.querySelector('.dictionary-menu-button'); + const detailsTable = node.querySelector('.dictionary-details-table'); + const detailsToggleLink = node.querySelector('.dictionary-details-toggle-link'); + const outdatedContainer = node.querySelector('.dictionary-outdated-notification'); + const titleNode = node.querySelector('.dictionary-title'); + const versionNode = node.querySelector('.dictionary-version'); + const wildcardSupportedCheckbox = node.querySelector('.dictionary-prefix-wildcard-searches-supported'); + + const hasDetails = (detailsTable !== null && this._setupDetails(detailsTable)); + this._hasDetails = hasDetails; + + titleNode.textContent = title; + versionNode.textContent = `rev.${revision}`; + if (wildcardSupportedCheckbox !== null) { + wildcardSupportedCheckbox.checked = !!prefixWildcardsSupported; + } + if (outdatedContainer !== null) { + outdatedContainer.hidden = (version >= 3); + } + if (detailsToggleLink !== null) { + detailsToggleLink.hidden = !hasDetails; + } + if (enabledCheckbox !== null) { + enabledCheckbox.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'enabled']); + this._eventListeners.addEventListener(enabledCheckbox, 'settingChanged', this._onEnabledChanged.bind(this), false); + } + if (priorityInput !== null) { + priorityInput.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'priority']); + } + if (allowSecondarySearchesCheckbox !== null) { + allowSecondarySearchesCheckbox.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'allowSecondarySearches']); + } + if (deleteButton !== null) { + this._eventListeners.addEventListener(deleteButton, 'click', this._onDeleteButtonClicked.bind(this), false); + } + if (menuButton !== null) { + this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); + this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + } + if (detailsToggleLink !== null && this._detailsContainer !== null) { + this._eventListeners.addEventListener(detailsToggleLink, 'click', this._onDetailsToggleLinkClicked.bind(this), false); + } + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + const node = this._node; + if (node.parentNode !== null) { + node.parentNode.removeChild(node); + } + } + + setCounts(counts) { + const node = this._node.querySelector('.dictionary-counts'); + node.textContent = JSON.stringify({info: this._dictionaryInfo, counts}, null, 4); + node.hidden = false; + this._hasCounts = true; + } + + // Private + + _onDeleteButtonClicked(e) { + e.preventDefault(); + this._delete(); + } + + _onMenuOpen(e) { + const bodyNode = e.detail.menu.bodyNode; + const showDetails = bodyNode.querySelector('.popup-menu-item[data-menu-action="showDetails"]'); + const hideDetails = bodyNode.querySelector('.popup-menu-item[data-menu-action="hideDetails"]'); + const hasDetails = (this._detailsContainer !== null); + const detailsVisible = (hasDetails && !this._detailsContainer.hidden); + if (showDetails !== null) { + showDetails.hidden = detailsVisible; + showDetails.disabled = !hasDetails; + } + if (hideDetails !== null) { + hideDetails.hidden = !detailsVisible; + hideDetails.disabled = !hasDetails; + } + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'delete': + this._delete(); + break; + case 'showDetails': + if (this._detailsContainer !== null) { this._detailsContainer.hidden = false; } + break; + case 'hideDetails': + if (this._detailsContainer !== null) { this._detailsContainer.hidden = true; } + break; + } + } + + _onDetailsToggleLinkClicked(e) { + e.preventDefault(); + this._detailsContainer.hidden = !this._detailsContainer.hidden; + } + + _onEnabledChanged(e) { + const {detail: {value}} = e; + this._node.dataset.enabled = `${value}`; + this._dictionaryController.updateDictionariesEnabled(); + } + + _setupDetails(detailsTable) { + const targets = [ + ['Author', 'author'], + ['URL', 'url'], + ['Description', 'description'], + ['Attribution', 'attribution'] + ]; + + const dictionaryInfo = this._dictionaryInfo; + const fragment = document.createDocumentFragment(); + let any = false; + for (const [label, key] of targets) { + const info = dictionaryInfo[key]; + if (typeof info !== 'string') { continue; } + + const details = 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; + fragment.appendChild(details); + + any = true; + } + + detailsTable.appendChild(fragment); + return any; + } + + _delete() { + this._dictionaryController.deleteDictionary(this.dictionaryTitle); + } +} + +class DictionaryController { + constructor(settingsController, modalController, storageController, statusFooter) { + this._settingsController = settingsController; + this._modalController = modalController; + this._storageController = storageController; + this._statusFooter = statusFooter; + this._dictionaries = null; + this._dictionaryEntries = []; + this._databaseStateToken = null; + this._checkingIntegrity = false; + this._checkIntegrityButton = null; + this._dictionaryEntryContainer = null; + this._integrityExtraInfoContainer = null; + this._dictionaryInstallCountNode = null; + this._dictionaryEnabledCountNode = null; + this._noDictionariesInstalledWarnings = null; + this._noDictionariesEnabledWarnings = null; + this._deleteDictionaryModal = null; + this._integrityExtraInfoNode = null; + this._isDeleting = false; + } + + async prepare() { + this._checkIntegrityButton = document.querySelector('#dictionary-check-integrity'); + this._dictionaryEntryContainer = document.querySelector('#dictionary-list'); + this._integrityExtraInfoContainer = document.querySelector('#dictionary-list-extra'); + 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._deleteDictionaryModal = this._modalController.getModal('dictionary-confirm-delete'); + + yomichan.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + document.querySelector('#dictionary-confirm-delete-button').addEventListener('click', this._onDictionaryConfirmDelete.bind(this), false); + if (this._checkIntegrityButton !== null) { + this._checkIntegrityButton.addEventListener('click', this._onCheckIntegrityButtonClick.bind(this), false); + } + + await this._onDatabaseUpdated(); + } + + deleteDictionary(dictionaryTitle) { + if (this._isDeleting) { return; } + const modal = this._deleteDictionaryModal; + modal.node.dataset.dictionaryTitle = dictionaryTitle; + modal.node.querySelector('#dictionary-confirm-delete-name').textContent = dictionaryTitle; + modal.setVisible(true); + } + + instantiateTemplate(name) { + return this._settingsController.instantiateTemplate(name); + } + + async updateDictionariesEnabled() { + const options = await this._settingsController.getOptions(); + this._updateDictionariesEnabledWarnings(options); + } + + // Private + + _onOptionsChanged({options}) { + this._updateDictionariesEnabledWarnings(options); + } + + async _onDatabaseUpdated() { + const token = {}; + this._databaseStateToken = token; + this._dictionaries = null; + const dictionaries = await this._settingsController.getDictionaryInfo(); + const options = await this._settingsController.getOptions(); + if (this._databaseStateToken !== token) { return; } + this._dictionaries = dictionaries; + + this._updateMainDictionarySelectOptions(dictionaries); + + for (const entry of this._dictionaryEntries) { + entry.cleanup(); + } + this._dictionaryEntries = []; + + if (this._dictionaryInstallCountNode !== null) { + this._dictionaryInstallCountNode.textContent = `${dictionaries.length}`; + } + + const hasDictionary = (dictionaries.length > 0); + for (const node of this._noDictionariesInstalledWarnings) { + node.hidden = hasDictionary; + } + + this._updateDictionariesEnabledWarnings(options); + + await this._ensureDictionarySettings(dictionaries); + for (const dictionary of dictionaries) { + this._createDictionaryEntry(dictionary); + } + } + + _updateDictionariesEnabledWarnings(options) { + let enabledCount = 0; + if (this._dictionaries !== null) { + for (const {title} of this._dictionaries) { + if (Object.prototype.hasOwnProperty.call(options.dictionaries, title)) { + const {enabled} = options.dictionaries[title]; + if (enabled) { + ++enabledCount; + } + } + } + } + + const hasEnabledDictionary = (enabledCount > 0); + for (const node of this._noDictionariesEnabledWarnings) { + node.hidden = hasEnabledDictionary; + } + + if (this._dictionaryEnabledCountNode !== null) { + this._dictionaryEnabledCountNode.textContent = `${enabledCount}`; + } + } + + _onDictionaryConfirmDelete(e) { + e.preventDefault(); + + const modal = this._deleteDictionaryModal; + modal.setVisible(false); + + const title = modal.node.dataset.dictionaryTitle; + if (typeof title !== 'string') { return; } + delete modal.node.dataset.dictionaryTitle; + + this._deleteDictionary(title); + } + + _onCheckIntegrityButtonClick(e) { + e.preventDefault(); + this._checkIntegrity(); + } + + _updateMainDictionarySelectOptions(dictionaries) { + for (const select of document.querySelectorAll('[data-setting="general.mainDictionary"]')) { + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.className = 'text-muted'; + option.value = ''; + option.textContent = 'Not selected'; + fragment.appendChild(option); + + for (const {title, sequenced} of dictionaries) { + if (!sequenced) { continue; } + option = document.createElement('option'); + option.value = title; + option.textContent = title; + fragment.appendChild(option); + } + + select.textContent = ''; // Empty + select.appendChild(fragment); + } + } + + async _checkIntegrity() { + if (this._dictionaries === null || this._checkingIntegrity || this._isDeleting) { return; } + + try { + this._checkingIntegrity = true; + this._setButtonsEnabled(false); + + const token = this._databaseStateToken; + const dictionaryTitles = this._dictionaries.map(({title}) => title); + const {counts, total} = await yomichan.api.getDictionaryCounts(dictionaryTitles, true); + if (this._databaseStateToken !== token) { return; } + + for (let i = 0, ii = Math.min(counts.length, this._dictionaryEntries.length); i < ii; ++i) { + const entry = this._dictionaryEntries[i]; + entry.setCounts(counts[i]); + } + + this._setCounts(counts, total); + } finally { + this._setButtonsEnabled(true); + this._checkingIntegrity = false; + } + } + + _setCounts(dictionaryCounts, totalCounts) { + const remainders = Object.assign({}, totalCounts); + const keys = Object.keys(remainders); + + for (const counts of dictionaryCounts) { + for (const key of keys) { + remainders[key] -= counts[key]; + } + } + + let totalRemainder = 0; + for (const key of keys) { + totalRemainder += remainders[key]; + } + + this._cleanupExtra(); + if (totalRemainder > 0) { + this.extra = this._createExtra(totalCounts, remainders, totalRemainder); + } + } + + _createExtra(totalCounts, remainders, totalRemainder) { + const node = this.instantiateTemplate('dictionary-extra'); + this._integrityExtraInfoNode = node; + + node.querySelector('.dictionary-total-count').textContent = `${totalRemainder} item${totalRemainder !== 1 ? 's' : ''}`; + + const n = node.querySelector('.dictionary-counts'); + n.textContent = JSON.stringify({counts: totalCounts, remainders}, null, 4); + n.hidden = false; + + this._integrityExtraInfoContainer.appendChild(node); + } + + _cleanupExtra() { + const node = this._integrityExtraInfoNode; + if (node === null) { return; } + this._integrityExtraInfoNode = null; + + const parent = node.parentNode; + if (parent === null) { return; } + + parent.removeChild(node); + } + + _createDictionaryEntry(dictionary) { + const node = this.instantiateTemplate('dictionary'); + this._dictionaryEntryContainer.appendChild(node); + + const entry = new DictionaryEntry(this, node, dictionary); + this._dictionaryEntries.push(entry); + entry.prepare(); + } + + async _deleteDictionary(dictionaryTitle) { + if (this._isDeleting || this._checkingIntegrity) { return; } + + const index = this._dictionaryEntries.findIndex((entry) => entry.dictionaryTitle === dictionaryTitle); + if (index < 0) { return; } + + const storageController = this._storageController; + const statusFooter = this._statusFooter; + const {node} = this._dictionaryEntries[index]; + const progressSelector = '.dictionary-delete-progress'; + const progressContainers = [ + ...node.querySelectorAll('.progress-container'), + ...document.querySelectorAll(`#dictionaries-modal ${progressSelector}`) + ]; + const progressBars = [ + ...node.querySelectorAll('.progress-bar'), + ...document.querySelectorAll(`${progressSelector} .progress-bar`) + ]; + const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`); + const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`); + const prevention = this._settingsController.preventPageExit(); + try { + this._isDeleting = true; + this._setButtonsEnabled(false); + + const onProgress = ({processed, count, storeCount, storesProcesed}) => { + const percent = ( + (count > 0 && storesProcesed > 0) ? + (processed / count) * (storesProcesed / storeCount) * 100.0 : + 0.0 + ); + const cssString = `${percent}%`; + const statusString = `${percent.toFixed(0)}%`; + for (const progressBar of progressBars) { progressBar.style.width = cssString; } + for (const label of statusLabels) { label.textContent = statusString; } + }; + + onProgress({processed: 0, count: 1, storeCount: 1, storesProcesed: 0}); + + for (const progress of progressContainers) { progress.hidden = false; } + for (const label of infoLabels) { label.textContent = 'Deleting dictionary...'; } + if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); } + + await this._deleteDictionaryInternal(dictionaryTitle, onProgress); + await this._deleteDictionarySettings(dictionaryTitle); + } catch (e) { + log.error(e); + } finally { + prevention.end(); + for (const progress of progressContainers) { progress.hidden = true; } + if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); } + this._setButtonsEnabled(true); + this._isDeleting = false; + if (storageController !== null) { storageController.updateStats(); } + } + } + + _setButtonsEnabled(value) { + value = !value; + for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) { + node.disabled = value; + } + } + + async _deleteDictionaryInternal(dictionaryTitle, onProgress) { + const dictionaryDatabase = await this._getPreparedDictionaryDatabase(); + try { + await dictionaryDatabase.deleteDictionary(dictionaryTitle, {rate: 1000}, onProgress); + yomichan.api.triggerDatabaseUpdated('dictionary', 'delete'); + } finally { + dictionaryDatabase.close(); + } + } + + async _getPreparedDictionaryDatabase() { + const dictionaryDatabase = new DictionaryDatabase(); + await dictionaryDatabase.prepare(); + return dictionaryDatabase; + } + + async _deleteDictionarySettings(dictionaryTitle) { + const optionsFull = await this._settingsController.getOptionsFull(); + const {profiles} = optionsFull; + const targets = []; + for (let i = 0, ii = profiles.length; i < ii; ++i) { + const {options: {dictionaries}} = profiles[i]; + if (Object.prototype.hasOwnProperty.call(dictionaries, dictionaryTitle)) { + const path = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', dictionaryTitle]); + targets.push({action: 'delete', path}); + } + } + await this._settingsController.modifyGlobalSettings(targets); + } + + async _ensureDictionarySettings(dictionaries2) { + const optionsFull = await this._settingsController.getOptionsFull(); + const {profiles} = optionsFull; + const targets = []; + for (const {title} of dictionaries2) { + for (let i = 0, ii = profiles.length; i < ii; ++i) { + const {options: {dictionaries: dictionaryOptions}} = profiles[i]; + if (Object.prototype.hasOwnProperty.call(dictionaryOptions, title)) { continue; } + + const path = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', title]); + targets.push({ + action: 'set', + path, + value: DictionaryController.createDefaultDictionarySettings() + }); + } + } + + if (targets.length > 0) { + await this._settingsController.modifyGlobalSettings(targets); + } + } + + static createDefaultDictionarySettings() { + return { + enabled: false, + allowSecondarySearches: false, + priority: 0 + }; + } +} diff --git a/ext/js/pages/settings/dictionary-import-controller.js b/ext/js/pages/settings/dictionary-import-controller.js new file mode 100644 index 00000000..1389b7f0 --- /dev/null +++ b/ext/js/pages/settings/dictionary-import-controller.js @@ -0,0 +1,345 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DictionaryDatabase + * DictionaryImporter + * ObjectPropertyAccessor + */ + +class DictionaryImportController { + constructor(settingsController, modalController, storageController, statusFooter) { + this._settingsController = settingsController; + this._modalController = modalController; + this._storageController = storageController; + this._statusFooter = statusFooter; + this._modifying = false; + this._purgeButton = null; + this._purgeConfirmButton = null; + this._importFileButton = null; + this._importFileInput = null; + this._purgeConfirmModal = null; + this._errorContainer = null; + this._spinner = null; + this._purgeNotification = null; + this._errorToStringOverrides = [ + [ + 'A mutation operation was attempted on a database that did not allow mutations.', + 'Access to IndexedDB appears to be restricted. Firefox seems to require that the history preference is set to "Remember history" before IndexedDB use of any kind is allowed.' + ], + [ + 'The operation failed for reasons unrelated to the database itself and not covered by any other error code.', + 'Unable to access IndexedDB due to a possibly corrupt user profile. Try using the "Refresh Firefox" feature to reset your user profile.' + ] + ]; + } + + 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._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._purgeButton.addEventListener('click', this._onPurgeButtonClick.bind(this), false); + this._purgeConfirmButton.addEventListener('click', this._onPurgeConfirmButtonClick.bind(this), false); + this._importFileButton.addEventListener('click', this._onImportButtonClick.bind(this), false); + this._importFileInput.addEventListener('change', this._onImportFileChange.bind(this), false); + } + + // Private + + _onImportButtonClick() { + this._importFileInput.click(); + } + + _onPurgeButtonClick(e) { + e.preventDefault(); + this._purgeConfirmModal.setVisible(true); + } + + _onPurgeConfirmButtonClick(e) { + e.preventDefault(); + this._purgeConfirmModal.setVisible(false); + this._purgeDatabase(); + } + + _onImportFileChange(e) { + const node = e.currentTarget; + const files = [...node.files]; + node.value = null; + this._importDictionaries(files); + } + + async _purgeDatabase() { + if (this._modifying) { return; } + + const purgeNotification = this._purgeNotification; + const storageController = this._storageController; + const prevention = this._preventPageExit(); + + try { + this._setModifying(true); + this._hideErrors(); + this._setSpinnerVisible(true); + if (purgeNotification !== null) { purgeNotification.hidden = false; } + + await yomichan.api.purgeDatabase(); + const errors = await this._clearDictionarySettings(); + + if (errors.length > 0) { + this._showErrors(errors); + } + } catch (error) { + this._showErrors([error]); + } finally { + prevention.end(); + if (purgeNotification !== null) { purgeNotification.hidden = true; } + this._setSpinnerVisible(false); + this._setModifying(false); + if (storageController !== null) { storageController.updateStats(); } + } + } + + async _importDictionaries(files) { + if (this._modifying) { return; } + + const statusFooter = this._statusFooter; + const storageController = this._storageController; + const importInfo = document.querySelector('#dictionary-import-info'); + const progressSelector = '.dictionary-import-progress'; + const progressContainers = [ + ...document.querySelectorAll('#dictionary-import-progress-container'), + ...document.querySelectorAll(`#dictionaries-modal ${progressSelector}`) + ]; + const progressBars = [ + ...document.querySelectorAll('#dictionary-import-progress-container .progress-bar'), + ...document.querySelectorAll(`${progressSelector} .progress-bar`) + ]; + const infoLabels = document.querySelectorAll(`${progressSelector} .progress-info`); + const statusLabels = document.querySelectorAll(`${progressSelector} .progress-status`); + + const prevention = this._preventPageExit(); + + try { + this._setModifying(true); + this._hideErrors(); + this._setSpinnerVisible(true); + + for (const progress of progressContainers) { progress.hidden = false; } + + const optionsFull = await this._settingsController.getOptionsFull(); + const importDetails = { + prefixWildcardsSupported: optionsFull.global.database.prefixWildcardsSupported + }; + + const onProgress = (total, current) => { + const percent = (current / total * 100.0); + const cssString = `${percent}%`; + const statusString = `${percent.toFixed(0)}%`; + for (const progressBar of progressBars) { progressBar.style.width = cssString; } + for (const label of statusLabels) { label.textContent = statusString; } + if (storageController !== null) { storageController.updateStats(); } + }; + + const fileCount = files.length; + for (let i = 0; i < fileCount; ++i) { + if (importInfo !== null && fileCount > 1) { + importInfo.hidden = false; + importInfo.textContent = `(${i + 1} of ${fileCount})`; + } + + onProgress(1, 0); + + const labelText = `Importing dictionary${fileCount > 1 ? ` (${i + 1} of ${fileCount})` : ''}...`; + for (const label of infoLabels) { label.textContent = labelText; } + if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, true); } + + await this._importDictionary(files[i], importDetails, onProgress); + } + } catch (err) { + this._showErrors([err]); + } finally { + prevention.end(); + for (const progress of progressContainers) { progress.hidden = true; } + if (statusFooter !== null) { statusFooter.setTaskActive(progressSelector, false); } + if (importInfo !== null) { + importInfo.textContent = ''; + importInfo.hidden = true; + } + this._setSpinnerVisible(false); + this._setModifying(false); + if (storageController !== null) { storageController.updateStats(); } + } + } + + async _importDictionary(file, importDetails, onProgress) { + const dictionaryDatabase = await this._getPreparedDictionaryDatabase(); + try { + const dictionaryImporter = new DictionaryImporter(); + const archiveContent = await this._readFile(file); + const {result, errors} = await dictionaryImporter.importDictionary(dictionaryDatabase, archiveContent, importDetails, onProgress); + yomichan.api.triggerDatabaseUpdated('dictionary', 'import'); + const errors2 = await this._addDictionarySettings(result.sequenced, result.title); + + if (errors.length > 0) { + const allErrors = [...errors, ...errors2]; + allErrors.push(new Error(`Dictionary may not have been imported properly: ${allErrors.length} error${allErrors.length === 1 ? '' : 's'} reported.`)); + this._showErrors(allErrors); + } + } finally { + dictionaryDatabase.close(); + } + } + + async _addDictionarySettings(sequenced, title) { + const optionsFull = await this._settingsController.getOptionsFull(); + const targets = []; + const profileCount = optionsFull.profiles.length; + for (let i = 0; i < profileCount; ++i) { + const {options} = optionsFull.profiles[i]; + const value = this._createDictionaryOptions(); + const path1 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries', title]); + targets.push({action: 'set', path: path1, value}); + + if (sequenced && options.general.mainDictionary === '') { + const path2 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'general', 'mainDictionary']); + targets.push({action: 'set', path: path2, value: title}); + } + } + return await this._modifyGlobalSettings(targets); + } + + async _clearDictionarySettings() { + const optionsFull = await this._settingsController.getOptionsFull(); + const targets = []; + const profileCount = optionsFull.profiles.length; + for (let i = 0; i < profileCount; ++i) { + const path1 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'dictionaries']); + targets.push({action: 'set', path: path1, value: {}}); + const path2 = ObjectPropertyAccessor.getPathString(['profiles', i, 'options', 'general', 'mainDictionary']); + targets.push({action: 'set', path: path2, value: ''}); + } + return await this._modifyGlobalSettings(targets); + } + + _setSpinnerVisible(visible) { + if (this._spinner !== null) { + this._spinner.hidden = !visible; + } + } + + _preventPageExit() { + return this._settingsController.preventPageExit(); + } + + _showErrors(errors) { + const uniqueErrors = new Map(); + for (const error of errors) { + log.error(error); + const errorString = this._errorToString(error); + let count = uniqueErrors.get(errorString); + if (typeof count === 'undefined') { + count = 0; + } + uniqueErrors.set(errorString, count + 1); + } + + const fragment = document.createDocumentFragment(); + for (const [e, count] of uniqueErrors.entries()) { + const div = document.createElement('p'); + if (count > 1) { + div.textContent = `${e} `; + const em = document.createElement('em'); + em.textContent = `(${count})`; + div.appendChild(em); + } else { + div.textContent = `${e}`; + } + fragment.appendChild(div); + } + + this._errorContainer.appendChild(fragment); + this._errorContainer.hidden = false; + } + + _hideErrors() { + this._errorContainer.textContent = ''; + this._errorContainer.hidden = true; + } + + _readFile(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error); + reader.readAsBinaryString(file); + }); + } + + _createDictionaryOptions() { + return { + priority: 0, + enabled: true, + allowSecondarySearches: false + }; + } + + _errorToString(error) { + error = (typeof error.toString === 'function' ? error.toString() : `${error}`); + + for (const [match, newErrorString] of this._errorToStringOverrides) { + if (error.includes(match)) { + return newErrorString; + } + } + + return error; + } + + _setModifying(value) { + this._modifying = value; + this._setButtonsEnabled(!value); + } + + _setButtonsEnabled(value) { + value = !value; + for (const node of document.querySelectorAll('.dictionary-database-mutating-input')) { + node.disabled = value; + } + } + + async _getPreparedDictionaryDatabase() { + const dictionaryDatabase = new DictionaryDatabase(); + await dictionaryDatabase.prepare(); + return dictionaryDatabase; + } + + 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)); + } + } + return errors; + } +} diff --git a/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js b/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js new file mode 100644 index 00000000..032f9dcc --- /dev/null +++ b/ext/js/pages/settings/extension-keyboard-shortcuts-controller.js @@ -0,0 +1,291 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * HotkeyUtil + * KeyboardMouseInputField + */ + +class ExtensionKeyboardShortcutController { + constructor(settingsController) { + this._settingsController = settingsController; + this._resetButton = null; + this._clearButton = null; + this._listContainer = null; + this._hotkeyUtil = new HotkeyUtil(); + this._os = null; + this._entries = []; + } + + 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'); + + const canResetCommands = this.canResetCommands(); + const canModifyCommands = this.canModifyCommands(); + this._resetButton.hidden = !canResetCommands; + this._clearButton.hidden = !canModifyCommands; + + if (canResetCommands) { + this._resetButton.addEventListener('click', this._onResetClick.bind(this)); + } + if (canModifyCommands) { + this._clearButton.addEventListener('click', this._onClearClick.bind(this)); + } + + const {platform: {os}} = await yomichan.api.getEnvironmentInfo(); + this._os = os; + this._hotkeyUtil.os = os; + + const commands = await this._getCommands(); + this._setupCommands(commands); + } + + async resetCommand(name) { + await this._resetCommand(name); + + let key = null; + let modifiers = []; + + const commands = await this._getCommands(); + for (const {name: name2, shortcut} of commands) { + if (name === name2) { + ({key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut)); + break; + } + } + + return {key, 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}); + } + + canResetCommands() { + return isObject(chrome.commands) && typeof chrome.commands.reset === 'function'; + } + + canModifyCommands() { + return isObject(chrome.commands) && typeof chrome.commands.update === 'function'; + } + + // Add + + _onResetClick(e) { + e.preventDefault(); + this._resetAllCommands(); + } + + _onClearClick(e) { + e.preventDefault(); + this._clearAllCommands(); + } + + _getCommands() { + return new Promise((resolve, reject) => { + if (!(isObject(chrome.commands) && typeof chrome.commands.getAll === 'function')) { + resolve([]); + return; + } + + chrome.commands.getAll((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + } + + _setupCommands(commands) { + for (const entry of this._entries) { + entry.cleanup(); + } + this._entries = []; + + const fragment = document.createDocumentFragment(); + + for (const {name, description, shortcut} of commands) { + if (name.startsWith('_')) { continue; } + + const {key, modifiers} = this._hotkeyUtil.convertCommandToInput(shortcut); + + const node = this._settingsController.instantiateTemplate('extension-hotkey-list-item'); + fragment.appendChild(node); + + const entry = new ExtensionKeyboardShortcutHotkeyEntry(this, node, name, description, key, modifiers, this._os); + entry.prepare(); + this._entries.push(entry); + } + + this._listContainer.textContent = ''; + this._listContainer.appendChild(fragment); + } + + async _resetAllCommands() { + if (!this.canModifyCommands()) { return; } + + let commands = await this._getCommands(); + const promises = []; + + for (const {name} of commands) { + if (name.startsWith('_')) { continue; } + promises.push(this._resetCommand(name)); + } + + await Promise.all(promises); + + commands = await this._getCommands(); + this._setupCommands(commands); + } + + async _clearAllCommands() { + if (!this.canModifyCommands()) { return; } + + let commands = await this._getCommands(); + const promises = []; + + for (const {name} of commands) { + if (name.startsWith('_')) { continue; } + promises.push(this.updateCommand(name, null, [])); + } + + await Promise.all(promises); + + commands = await this._getCommands(); + this._setupCommands(commands); + } + + async _resetCommand(name) { + // Firefox-only; uses Promise API + return await chrome.commands.reset(name); + } +} + +class ExtensionKeyboardShortcutHotkeyEntry { + constructor(parent, node, name, description, key, modifiers, os) { + this._parent = parent; + this._node = node; + this._name = name; + this._description = description; + this._key = key; + this._modifiers = modifiers; + this._os = os; + this._input = null; + this._inputField = null; + this._eventListeners = new EventListenerCollection(); + } + + prepare() { + this._node.querySelector('.settings-item-label').textContent = this._description || this._name; + + const button = this._node.querySelector('.extension-hotkey-list-item-button'); + const input = this._node.querySelector('input'); + + this._input = input; + + if (this._parent.canModifyCommands()) { + this._inputField = new KeyboardMouseInputField(input, null, this._os); + this._inputField.prepare(this._key, this._modifiers, false, true); + this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this)); + this._eventListeners.addEventListener(button, 'menuClose', this._onMenuClose.bind(this)); + this._eventListeners.addEventListener(input, 'blur', this._onInputFieldBlur.bind(this)); + } else { + input.readOnly = true; + input.value = this._parent.hotkeyUtil.getInputDisplayValue(this._key, this._modifiers); + button.hidden = true; + } + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + if (this._node.parentNode !== null) { + this._node.parentNode.removeChild(this._node); + } + if (this._inputField !== null) { + this._inputField.cleanup(); + this._inputField = null; + } + } + + // Private + + _onInputFieldChange(e) { + const {key, modifiers} = e; + this._tryUpdateInput(key, modifiers, false); + } + + _onInputFieldBlur() { + this._updateInput(); + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'clearInput': + this._tryUpdateInput(null, [], true); + break; + case 'resetInput': + this._resetInput(); + break; + } + } + + _updateInput() { + this._inputField.setInput(this._key, this._modifiers); + delete this._input.dataset.invalid; + } + + async _tryUpdateInput(key, modifiers, updateInput) { + let okay = (key === null ? (modifiers.length === 0) : (modifiers.length !== 0)); + if (okay) { + try { + await this._parent.updateCommand(this._name, key, modifiers); + } catch (e) { + okay = false; + } + } + + if (okay) { + this._key = key; + this._modifiers = modifiers; + delete this._input.dataset.invalid; + } else { + this._input.dataset.invalid = 'true'; + } + + if (updateInput) { + this._updateInput(); + } + } + + async _resetInput() { + const {key, modifiers} = await this._parent.resetCommand(this._name); + this._key = key; + this._modifiers = modifiers; + this._updateInput(); + } +} diff --git a/ext/js/pages/settings/generic-setting-controller.js b/ext/js/pages/settings/generic-setting-controller.js new file mode 100644 index 00000000..7d6fc2e6 --- /dev/null +++ b/ext/js/pages/settings/generic-setting-controller.js @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* globals + * DOMDataBinder + */ + +class GenericSettingController { + constructor(settingsController) { + this._settingsController = settingsController; + this._defaultScope = 'profile'; + this._dataBinder = new DOMDataBinder({ + selector: '[data-setting]', + createElementMetadata: this._createElementMetadata.bind(this), + compareElementMetadata: this._compareElementMetadata.bind(this), + getValues: this._getValues.bind(this), + setValues: this._setValues.bind(this) + }); + this._transforms = new Map([ + ['setAttribute', this._setAttribute.bind(this)], + ['setVisibility', this._setVisibility.bind(this)], + ['splitTags', this._splitTags.bind(this)], + ['joinTags', this._joinTags.bind(this)], + ['toNumber', this._toNumber.bind(this)], + ['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(); + } + + _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 = []; + } + return { + path, + scope, + transforms, + transformRaw + }; + } + + _compareElementMetadata(metadata1, metadata2) { + return ( + metadata1.path === metadata2.path && + metadata1.scope === metadata2.scope && + metadata1.transformRaw === metadata2.transformRaw + ); + } + + async _getValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata: {path, scope}} of targets) { + const target = { + path, + scope: scope || defaultScope + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.getSettings(settingsTargets), targets); + } + + async _setValues(targets) { + const defaultScope = this._defaultScope; + const settingsTargets = []; + for (const {metadata: {path, scope, transforms}, value, element} of targets) { + const transformedValue = this._applyTransforms(value, transforms, 'pre', element); + const target = { + path, + scope: scope || defaultScope, + action: 'set', + value: transformedValue + }; + settingsTargets.push(target); + } + return this._transformResults(await this._settingsController.modifySettings(settingsTargets), targets); + } + + _transformResults(values, targets) { + return values.map((value, i) => { + const error = value.error; + if (error) { return deserializeError(error); } + const {metadata: {transforms}, element} = targets[i]; + const result = this._applyTransforms(value.result, transforms, 'post', element); + return {result}; + }); + } + + _applyTransforms(value, transforms, step, element) { + for (const transform of transforms) { + const transformStep = transform.step; + if (typeof transformStep !== 'undefined' && transformStep !== step) { continue; } + + const transformFunction = this._transforms.get(transform.type); + if (typeof transformFunction === 'undefined') { continue; } + + value = transformFunction(value, transform, element); + } + return value; + } + + _getAncestor(node, ancestorDistance) { + if (ancestorDistance < 0) { + return document.documentElement; + } + for (let i = 0; i < ancestorDistance && node !== null; ++i) { + node = node.parentNode; + } + return node; + } + + _getRelativeElement(node, ancestorDistance, selector) { + const selectorRoot = ( + typeof ancestorDistance === 'number' ? + this._getAncestor(node, ancestorDistance) : + document + ); + if (selectorRoot === null) { return null; } + + return ( + typeof selector === 'string' ? + selectorRoot.querySelector(selector) : + (selectorRoot === document ? document.documentElement : selectorRoot) + ); + } + + _evaluateSimpleOperation(operation, lhs, rhs) { + switch (operation) { + case '!': return !lhs; + 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; + default: return false; + } + } + + // Transforms + + _setAttribute(value, data, element) { + const {ancestorDistance, selector, attribute} = data; + const relativeElement = this._getRelativeElement(element, ancestorDistance, selector); + if (relativeElement !== null) { + relativeElement.setAttribute(attribute, `${value}`); + } + return value; + } + + _setVisibility(value, data, element) { + const {ancestorDistance, selector, condition} = data; + const relativeElement = this._getRelativeElement(element, ancestorDistance, selector); + if (relativeElement !== null) { + relativeElement.hidden = !this._evaluateSimpleOperation(condition.op, value, condition.value); + } + return value; + } + + _splitTags(value) { + return `${value}`.split(/[,; ]+/).filter((v) => !!v); + } + + _joinTags(value) { + return value.join(' '); + } + + _toNumber(value, data) { + let {constraints} = data; + if (!isObject(constraints)) { constraints = {}; } + return DOMDataBinder.convertToNumber(value, constraints); + } + + _toBoolean(value) { + return (value === 'true'); + } + + _toString(value) { + return `${value}`; + } + + _conditionalConvert(value, data) { + const {cases} = data; + if (Array.isArray(cases)) { + for (const {op, value: value2, default: isDefault, result} of cases) { + if (isDefault === true) { + value = result; + } else if (this._evaluateSimpleOperation(op, value, value2)) { + value = result; + break; + } + } + } + return value; + } +} diff --git a/ext/js/pages/settings/keyboard-mouse-input-field.js b/ext/js/pages/settings/keyboard-mouse-input-field.js new file mode 100644 index 00000000..09477519 --- /dev/null +++ b/ext/js/pages/settings/keyboard-mouse-input-field.js @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DocumentUtil + * HotkeyUtil + */ + +class KeyboardMouseInputField extends EventDispatcher { + constructor(inputNode, mouseButton, os, isPointerTypeSupported=null) { + super(); + this._inputNode = inputNode; + this._mouseButton = mouseButton; + this._isPointerTypeSupported = isPointerTypeSupported; + this._hotkeyUtil = new HotkeyUtil(os); + this._eventListeners = new EventListenerCollection(); + this._key = null; + this._modifiers = []; + this._penPointerIds = new Set(); + this._mouseModifiersSupported = false; + this._keySupported = false; + } + + get modifiers() { + return this._modifiers; + } + + prepare(key, modifiers, mouseModifiersSupported=false, keySupported=false) { + this.cleanup(); + + this._mouseModifiersSupported = mouseModifiersSupported; + this._keySupported = keySupported; + this.setInput(key, modifiers); + const events = [ + [this._inputNode, 'keydown', this._onModifierKeyDown.bind(this), false], + [this._inputNode, 'keyup', this._onModifierKeyUp.bind(this), false] + ]; + if (mouseModifiersSupported && this._mouseButton !== null) { + events.push( + [this._mouseButton, 'mousedown', this._onMouseButtonMouseDown.bind(this), false], + [this._mouseButton, 'pointerdown', this._onMouseButtonPointerDown.bind(this), false], + [this._mouseButton, 'pointerover', this._onMouseButtonPointerOver.bind(this), false], + [this._mouseButton, 'pointerout', this._onMouseButtonPointerOut.bind(this), false], + [this._mouseButton, 'pointercancel', this._onMouseButtonPointerCancel.bind(this), false], + [this._mouseButton, 'mouseup', this._onMouseButtonMouseUp.bind(this), false], + [this._mouseButton, 'contextmenu', this._onMouseButtonContextMenu.bind(this), false] + ); + } + for (const args of events) { + this._eventListeners.addEventListener(...args); + } + } + + setInput(key, modifiers) { + this._key = key; + this._modifiers = this._sortModifiers(modifiers); + this._updateDisplayString(); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + this._modifiers = []; + this._key = null; + this._mouseModifiersSupported = false; + this._keySupported = false; + this._penPointerIds.clear(); + } + + clearInputs() { + this._updateModifiers([], null); + } + + // Private + + _sortModifiers(modifiers) { + return this._hotkeyUtil.sortModifiers(modifiers); + } + + _updateDisplayString() { + const displayValue = this._hotkeyUtil.getInputDisplayValue(this._key, this._modifiers); + this._inputNode.value = displayValue; + } + + _getModifierKeys(e) { + const modifiers = new Set(DocumentUtil.getActiveModifiers(e)); + // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/metaKey + // https://askubuntu.com/questions/567731/why-is-shift-alt-being-mapped-to-meta + // It works with mouse events on some platforms, so try to determine if metaKey is pressed. + // This is a hack and only works when both Shift and Alt are not pressed. + if ( + !modifiers.has('meta') && + e.key === 'Meta' && + !( + modifiers.size === 2 && + modifiers.has('shift') && + modifiers.has('alt') + ) + ) { + modifiers.add('meta'); + } + return modifiers; + } + + _isModifierKey(keyName) { + switch (keyName) { + case 'AltLeft': + case 'AltRight': + case 'ControlLeft': + case 'ControlRight': + case 'MetaLeft': + case 'MetaRight': + case 'ShiftLeft': + case 'ShiftRight': + case 'OSLeft': + case 'OSRight': + return true; + default: + return false; + } + } + + _onModifierKeyDown(e) { + e.preventDefault(); + + let key = e.code; + if (key === 'Unidentified' || key === '') { key = void 0; } + if (this._keySupported) { + this._updateModifiers([...this._getModifierKeys(e)], this._isModifierKey(key) ? void 0 : key); + } else { + switch (key) { + case 'Escape': + case 'Backspace': + this.clearInputs(); + break; + default: + this._addModifiers(this._getModifierKeys(e)); + break; + } + } + } + + _onModifierKeyUp(e) { + e.preventDefault(); + } + + _onMouseButtonMouseDown(e) { + e.preventDefault(); + this._addModifiers(DocumentUtil.getActiveButtons(e)); + } + + _onMouseButtonPointerDown(e) { + if (!e.isPrimary) { return; } + + let {pointerType, pointerId} = e; + // Workaround for Firefox bug not detecting certain 'touch' events as 'pen' events. + if (this._penPointerIds.has(pointerId)) { pointerType = 'pen'; } + + if ( + typeof this._isPointerTypeSupported !== 'function' || + !this._isPointerTypeSupported(pointerType) + ) { + return; + } + e.preventDefault(); + this._addModifiers(DocumentUtil.getActiveButtons(e)); + } + + _onMouseButtonPointerOver(e) { + const {pointerType, pointerId} = e; + if (pointerType === 'pen') { + this._penPointerIds.add(pointerId); + } + } + + _onMouseButtonPointerOut(e) { + const {pointerId} = e; + this._penPointerIds.delete(pointerId); + } + + _onMouseButtonPointerCancel(e) { + this._onMouseButtonPointerOut(e); + } + + _onMouseButtonMouseUp(e) { + e.preventDefault(); + } + + _onMouseButtonContextMenu(e) { + e.preventDefault(); + } + + _addModifiers(newModifiers, newKey) { + const modifiers = new Set(this._modifiers); + for (const modifier of newModifiers) { + modifiers.add(modifier); + } + this._updateModifiers([...modifiers], newKey); + } + + _updateModifiers(modifiers, newKey) { + modifiers = this._sortModifiers(modifiers); + + let changed = false; + if (typeof newKey !== 'undefined' && this._key !== newKey) { + this._key = newKey; + changed = true; + } + if (!this._areArraysEqual(this._modifiers, modifiers)) { + this._modifiers = modifiers; + changed = true; + } + + this._updateDisplayString(); + if (changed) { + this.trigger('change', {modifiers: this._modifiers, key: this._key}); + } + } + + _areArraysEqual(array1, array2) { + const length = array1.length; + if (length !== array2.length) { return false; } + + for (let i = 0; i < length; ++i) { + if (array1[i] !== array2[i]) { return false; } + } + + return true; + } +} diff --git a/ext/js/pages/settings/keyboard-shortcuts-controller.js b/ext/js/pages/settings/keyboard-shortcuts-controller.js new file mode 100644 index 00000000..99b16f06 --- /dev/null +++ b/ext/js/pages/settings/keyboard-shortcuts-controller.js @@ -0,0 +1,367 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * KeyboardMouseInputField + */ + +class KeyboardShortcutController { + constructor(settingsController) { + this._settingsController = settingsController; + this._entries = []; + this._os = null; + this._addButton = null; + this._resetButton = null; + this._listContainer = null; + this._emptyIndicator = null; + this._stringComparer = new Intl.Collator('en-US'); // Invariant locale + this._scrollContainer = null; + } + + get settingsController() { + return this._settingsController; + } + + async prepare() { + const {platform: {os}} = await yomichan.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.addEventListener('click', this._onAddClick.bind(this)); + this._resetButton.addEventListener('click', this._onResetClick.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + await this._updateOptions(); + } + + async addEntry(terminationCharacterEntry) { + const options = await this._settingsController.getOptions(); + const {inputs: {hotkeys}} = options; + + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'inputs.hotkeys', + start: hotkeys.length, + deleteCount: 0, + items: [terminationCharacterEntry] + }]); + + await this._updateOptions(); + this._scrollContainer.scrollTop = this._scrollContainer.scrollHeight; + } + + async deleteEntry(index) { + const options = await this._settingsController.getOptions(); + const {inputs: {hotkeys}} = options; + + if (index < 0 || index >= hotkeys.length) { return false; } + + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'inputs.hotkeys', + start: index, + deleteCount: 1, + items: [] + }]); + + await this._updateOptions(); + return true; + } + + async modifyProfileSettings(targets) { + return await this._settingsController.modifyProfileSettings(targets); + } + + async getDefaultHotkeys() { + const defaultOptions = await this._settingsController.getDefaultOptions(); + return defaultOptions.profiles[0].options.inputs.hotkeys; + } + + // Private + + _onOptionsChanged({options}) { + for (const entry of this._entries) { + entry.cleanup(); + } + + this._entries = []; + 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'); + fragment.appendChild(node); + const entry = new KeyboardShortcutHotkeyEntry(this, hotkeyEntry, i, node, this._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); + } + + _onAddClick(e) { + e.preventDefault(); + this._addNewEntry(); + } + + _onResetClick(e) { + e.preventDefault(); + this._reset(); + } + + async _addNewEntry() { + const newEntry = { + action: '', + key: null, + modifiers: [], + scopes: ['popup', 'search'], + enabled: true + }; + return await this.addEntry(newEntry); + } + + async _updateOptions() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + async _reset() { + const value = await this.getDefaultHotkeys(); + await this._settingsController.setProfileSetting('inputs.hotkeys', value); + await this._updateOptions(); + } +} + +class KeyboardShortcutHotkeyEntry { + constructor(parent, data, index, node, os, stringComparer) { + this._parent = parent; + this._data = data; + this._index = index; + this._node = node; + this._os = os; + this._eventListeners = new EventListenerCollection(); + this._inputField = null; + this._actionSelect = null; + this._scopeCheckboxes = null; + this._scopeCheckboxContainers = null; + this._basePath = `inputs.hotkeys[${this._index}]`; + this._stringComparer = stringComparer; + } + + 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 scopeCheckboxes = node.querySelectorAll('.hotkey-scope-checkbox'); + const scopeCheckboxContainers = node.querySelectorAll('.hotkey-scope-checkbox-container'); + const enabledToggle = node.querySelector('.hotkey-list-item-enabled'); + + this._actionSelect = action; + this._scopeCheckboxes = scopeCheckboxes; + this._scopeCheckboxContainers = scopeCheckboxContainers; + + this._inputField = new KeyboardMouseInputField(input, null, this._os); + this._inputField.prepare(this._data.key, this._data.modifiers, false, true); + + action.value = this._data.action; + + enabledToggle.checked = this._data.enabled; + enabledToggle.dataset.setting = `${this._basePath}.enabled`; + + this._updateCheckboxVisibility(); + this._updateCheckboxStates(); + + for (const scopeCheckbox of scopeCheckboxes) { + this._eventListeners.addEventListener(scopeCheckbox, 'change', this._onScopeCheckboxChange.bind(this), false); + } + this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + this._eventListeners.addEventListener(this._actionSelect, 'change', this._onActionSelectChange.bind(this), false); + this._eventListeners.on(this._inputField, 'change', this._onInputFieldChange.bind(this)); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + this._inputField.cleanup(); + if (this._node.parentNode !== null) { + this._node.parentNode.removeChild(this._node); + } + } + + // Private + + _onMenuClose(e) { + switch (e.detail.action) { + case 'delete': + this._delete(); + break; + case 'clearInputs': + this._inputField.clearInputs(); + break; + case 'resetInput': + this._resetInput(); + break; + } + } + + _onInputFieldChange({key, modifiers}) { + this._setKeyAndModifiers(key, modifiers); + } + + _onScopeCheckboxChange(e) { + const node = e.currentTarget; + const {scope} = node.dataset; + if (typeof scope !== 'string') { return; } + this._setScopeEnabled(scope, node.checked); + } + + _onActionSelectChange(e) { + const value = e.currentTarget.value; + this._setAction(value); + } + + async _delete() { + this._parent.deleteEntry(this._index); + } + + async _setKeyAndModifiers(key, modifiers) { + this._data.key = key; + this._data.modifiers = modifiers; + await this._modifyProfileSettings([ + { + action: 'set', + path: `${this._basePath}.key`, + value: key + }, + { + action: 'set', + path: `${this._basePath}.modifiers`, + value: modifiers + } + ]); + } + + async _setScopeEnabled(scope, enabled) { + const scopes = this._data.scopes; + const index = scopes.indexOf(scope); + if ((index >= 0) === enabled) { return; } + + if (enabled) { + scopes.push(scope); + const stringComparer = this._stringComparer; + scopes.sort((scope1, scope2) => stringComparer.compare(scope1, scope2)); + } else { + scopes.splice(index, 1); + } + + await this._modifyProfileSettings([{ + action: 'set', + path: `${this._basePath}.scopes`, + value: scopes + }]); + } + + 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); + if (defaultValue === null) { return; } + + const {key, modifiers} = defaultValue; + await this._setKeyAndModifiers(key, modifiers); + this._inputField.setInput(key, modifiers); + } + + _getDefaultKeyAndModifiers(defaultHotkeys, action) { + for (const {action: action2, key, modifiers} of defaultHotkeys) { + if (action2 !== action) { continue; } + return {modifiers, key}; + } + return null; + } + + async _setAction(value) { + const targets = [{ + action: 'set', + path: `${this._basePath}.action`, + value + }]; + + this._data.action = value; + + const scopes = this._data.scopes; + const validScopes = this._getValidScopesForAction(value); + if (validScopes !== null) { + let changed = false; + for (let i = 0, ii = scopes.length; i < ii; ++i) { + if (!validScopes.has(scopes[i])) { + scopes.splice(i, 1); + --i; + --ii; + changed = true; + } + } + if (changed) { + if (scopes.length === 0) { + scopes.push(...validScopes); + } + targets.push({ + action: 'set', + path: `${this._basePath}.scopes`, + value: scopes + }); + this._updateCheckboxStates(); + } + } + + await this._modifyProfileSettings(targets); + + this._updateCheckboxVisibility(); + } + + _updateCheckboxStates() { + const scopes = this._data.scopes; + for (const scopeCheckbox of this._scopeCheckboxes) { + scopeCheckbox.checked = scopes.includes(scopeCheckbox.dataset.scope); + } + } + + _updateCheckboxVisibility() { + const validScopes = this._getValidScopesForAction(this._data.action); + for (const node of this._scopeCheckboxContainers) { + node.hidden = !(validScopes === null || validScopes.has(node.dataset.scope)); + } + } + + _getValidScopesForAction(action) { + const optionNode = this._actionSelect.querySelector(`option[value="${action}"]`); + const scopesString = (optionNode !== null ? optionNode.dataset.scopes : void 0); + return (typeof scopesString === 'string' ? new Set(scopesString.split(' ')) : null); + } +} diff --git a/ext/js/pages/settings/main.js b/ext/js/pages/settings/main.js new file mode 100644 index 00000000..9785ee0e --- /dev/null +++ b/ext/js/pages/settings/main.js @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2016-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiController + * AnkiTemplatesController + * AudioController + * BackupController + * DictionaryController + * DictionaryImportController + * GenericSettingController + * ModalController + * PermissionsToggleController + * PopupPreviewController + * ProfileController + * ScanInputsController + * ScanInputsSimpleController + * SettingsController + * StorageController + */ + +function showExtensionInformation() { + const node = document.getElementById('extension-info'); + if (node === null) { return; } + + const manifest = chrome.runtime.getManifest(); + node.textContent = `${manifest.name} v${manifest.version}`; +} + +async function setupEnvironmentInfo() { + const {browser, platform} = await yomichan.api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.operatingSystem = platform.os; +} + + +(async () => { + try { + await yomichan.prepare(); + + setupEnvironmentInfo(); + showExtensionInformation(); + + const optionsFull = await yomichan.api.optionsGetFull(); + + const modalController = new ModalController(); + modalController.prepare(); + + const settingsController = new SettingsController(optionsFull.profileCurrent); + settingsController.prepare(); + + const storageController = new StorageController(); + storageController.prepare(); + + const genericSettingController = new GenericSettingController(settingsController); + genericSettingController.prepare(); + + const permissionsToggleController = new PermissionsToggleController(settingsController); + permissionsToggleController.prepare(); + + const popupPreviewController = new PopupPreviewController(settingsController); + popupPreviewController.prepare(); + + const audioController = new AudioController(settingsController); + audioController.prepare(); + + const profileController = new ProfileController(settingsController, modalController); + profileController.prepare(); + + const dictionaryController = new DictionaryController(settingsController, modalController, storageController, null); + dictionaryController.prepare(); + + const dictionaryImportController = new DictionaryImportController(settingsController, modalController, storageController, null); + dictionaryImportController.prepare(); + + const ankiController = new AnkiController(settingsController); + ankiController.prepare(); + + const ankiTemplatesController = new AnkiTemplatesController(settingsController, modalController, ankiController); + ankiTemplatesController.prepare(); + + const settingsBackup = new BackupController(settingsController, modalController); + settingsBackup.prepare(); + + const scanInputsController = new ScanInputsController(settingsController); + scanInputsController.prepare(); + + const simpleScanningInputController = new ScanInputsSimpleController(settingsController); + simpleScanningInputController.prepare(); + + yomichan.ready(); + } catch (e) { + log.error(e); + } +})(); diff --git a/ext/js/pages/settings/mecab-controller.js b/ext/js/pages/settings/mecab-controller.js new file mode 100644 index 00000000..122f82f9 --- /dev/null +++ b/ext/js/pages/settings/mecab-controller.js @@ -0,0 +1,63 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class MecabController { + constructor(settingsController) { + this._settingsController = settingsController; + this._testButton = null; + this._resultsContainer = null; + this._testActive = false; + } + + prepare() { + this._testButton = document.querySelector('#test-mecab-button'); + this._resultsContainer = document.querySelector('#test-mecab-results'); + + this._testButton.addEventListener('click', this._onTestButtonClick.bind(this), false); + } + + // Private + + _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; + await yomichan.api.testMecab(); + this._setStatus('Connection was successful', false); + } catch (e) { + this._setStatus(e.message, true); + } finally { + this._testActive = false; + this._testButton.disabled = false; + } + } + + _setStatus(message, isError) { + this._resultsContainer.textContent = message; + this._resultsContainer.hidden = false; + this._resultsContainer.classList.toggle('danger-text', isError); + } +} diff --git a/ext/js/pages/settings/modal-controller.js b/ext/js/pages/settings/modal-controller.js new file mode 100644 index 00000000..fe4f911b --- /dev/null +++ b/ext/js/pages/settings/modal-controller.js @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * Modal + */ + +class ModalController { + constructor() { + this._modals = []; + this._modalMap = new Map(); + } + + prepare() { + const idSuffix = '-modal'; + for (const node of document.querySelectorAll('.modal')) { + let {id} = node; + if (typeof id !== 'string') { continue; } + + if (id.endsWith(idSuffix)) { + id = id.substring(0, id.length - idSuffix.length); + } + + const modal = new Modal(node); + modal.prepare(); + this._modalMap.set(id, modal); + this._modalMap.set(node, modal); + this._modals.push(modal); + } + } + + getModal(nameOrNode) { + const modal = this._modalMap.get(nameOrNode); + return (typeof modal !== 'undefined' ? modal : null); + } + + getTopVisibleModal() { + for (let i = this._modals.length - 1; i >= 0; --i) { + const modal = this._modals[i]; + if (modal.isVisible()) { + return modal; + } + } + return null; + } +} diff --git a/ext/js/pages/settings/modal-jquery.js b/ext/js/pages/settings/modal-jquery.js new file mode 100644 index 00000000..8c69ae6d --- /dev/null +++ b/ext/js/pages/settings/modal-jquery.js @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class Modal extends EventDispatcher { + constructor(node) { + super(); + this._node = node; + this._eventListeners = new EventListenerCollection(); + } + + get node() { + return this._node; + } + + prepare() { + // NOP + } + + isVisible() { + return !!(this._getWrappedNode().data('bs.modal') || {}).isShown; + } + + setVisible(value) { + this._getWrappedNode().modal(value ? 'show' : 'hide'); + } + + on(eventName, callback) { + if (eventName === 'visibilityChanged') { + if (this._eventListeners.size === 0) { + const wrappedNode = this._getWrappedNode(); + this._eventListeners.on(wrappedNode, 'hidden.bs.modal', this._onModalHide.bind(this)); + this._eventListeners.on(wrappedNode, 'shown.bs.modal', this._onModalShow.bind(this)); + } + } + return super.on(eventName, callback); + } + + off(eventName, callback) { + const result = super.off(eventName, callback); + if (eventName === 'visibilityChanged' && !this.hasListeners(eventName)) { + this._eventListeners.removeAllEventListeners(); + } + return result; + } + + // Private + + _onModalHide() { + this.trigger('visibilityChanged', {visible: false}); + } + + _onModalShow() { + this.trigger('visibilityChanged', {visible: true}); + } + + _getWrappedNode() { + return $(this._node); + } +} diff --git a/ext/js/pages/settings/modal.js b/ext/js/pages/settings/modal.js new file mode 100644 index 00000000..2ef49540 --- /dev/null +++ b/ext/js/pages/settings/modal.js @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * PanelElement + */ + +class Modal extends PanelElement { + constructor(node) { + super({ + node, + closingAnimationDuration: 375 // Milliseconds; includes buffer + }); + this._contentNode = null; + this._canCloseOnClick = false; + } + + prepare() { + const node = this.node; + this._contentNode = node.querySelector('.modal-content'); + let dimmerNode = 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]')) { + actionNode.addEventListener('click', this._onActionNodeClick.bind(this), false); + } + } + + // Private + + _onModalContainerMouseDown(e) { + this._canCloseOnClick = (e.currentTarget === e.target); + } + + _onModalContainerMouseUp(e) { + if (!this._canCloseOnClick) { return; } + this._canCloseOnClick = (e.currentTarget === e.target); + } + + _onModalContainerClick(e) { + if (!this._canCloseOnClick) { return; } + this._canCloseOnClick = false; + if (e.currentTarget !== e.target) { return; } + this.setVisible(false); + } + + _onActionNodeClick(e) { + const {modalAction} = e.currentTarget.dataset; + switch (modalAction) { + case 'expand': + this._setExpanded(true); + break; + case 'collapse': + this._setExpanded(false); + break; + } + } + + _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 new file mode 100644 index 00000000..1ebc7389 --- /dev/null +++ b/ext/js/pages/settings/nested-popups-controller.js @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DOMDataBinder + */ + +class NestedPopupsController { + constructor(settingsController) { + this._settingsController = settingsController; + this._popupNestingMaxDepth = 0; + } + + 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'); + + const options = await this._settingsController.getOptions(); + + 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}); + } + + // Private + + _onOptionsChanged({options}) { + this._updatePopupNestingMaxDepth(options.scanning.popupNestingMaxDepth); + } + + _onNestedPopupsEnabledChange(e) { + const value = e.currentTarget.checked; + if (value && this._popupNestingMaxDepth > 0) { return; } + this._setPopupNestingMaxDepth(value ? 1 : 0); + } + + _onNestedPopupsCountChange(e) { + const node = e.currentTarget; + const value = Math.max(1, DOMDataBinder.convertToNumber(node.value, node)); + this._setPopupNestingMaxDepth(value); + } + + _updatePopupNestingMaxDepth(value) { + const enabled = (value > 0); + this._popupNestingMaxDepth = value; + this._nestedPopupsEnabled.checked = enabled; + this._nestedPopupsCount.value = `${value}`; + this._nestedPopupsEnabledMoreOptions.hidden = !enabled; + } + + async _setPopupNestingMaxDepth(value) { + this._updatePopupNestingMaxDepth(value); + await this._settingsController.setProfileSetting('scanning.popupNestingMaxDepth', value); + } +} diff --git a/ext/js/pages/settings/permissions-toggle-controller.js b/ext/js/pages/settings/permissions-toggle-controller.js new file mode 100644 index 00000000..f80e7585 --- /dev/null +++ b/ext/js/pages/settings/permissions-toggle-controller.js @@ -0,0 +1,131 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * ObjectPropertyAccessor + */ + +class PermissionsToggleController { + constructor(settingsController) { + this._settingsController = settingsController; + this._toggles = null; + } + + async prepare() { + this._toggles = document.querySelectorAll('.permissions-toggle'); + + for (const toggle of this._toggles) { + toggle.addEventListener('change', this._onPermissionsToggleChange.bind(this), false); + } + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + this._settingsController.on('permissionsChanged', this._onPermissionsChanged.bind(this)); + + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onOptionsChanged({options}) { + let accessor = null; + for (const toggle of this._toggles) { + const {permissionsSetting} = toggle.dataset; + if (typeof permissionsSetting !== 'string') { continue; } + + if (accessor === null) { + accessor = new ObjectPropertyAccessor(options); + } + + const path = ObjectPropertyAccessor.getPathArray(permissionsSetting); + let value; + try { + value = accessor.get(path, path.length); + } catch (e) { + continue; + } + toggle.checked = !!value; + } + this._updateValidity(); + } + + async _onPermissionsToggleChange(e) { + const toggle = e.currentTarget; + let value = toggle.checked; + const valuePre = !value; + const {permissionsSetting} = toggle.dataset; + const hasPermissionsSetting = typeof permissionsSetting === 'string'; + + if (value || !hasPermissionsSetting) { + toggle.checked = valuePre; + const permissions = this._getRequiredPermissions(toggle); + try { + value = await this._settingsController.permissionsUtil.setPermissionsGranted({permissions}, value); + } catch (error) { + value = valuePre; + try { + value = await this._settingsController.permissionsUtil.hasPermissions({permissions}); + } catch (error2) { + // NOP + } + } + toggle.checked = value; + } + + if (hasPermissionsSetting) { + this._setToggleValid(toggle, true); + await this._settingsController.setProfileSetting(permissionsSetting, value); + } + } + + _onPermissionsChanged({permissions: {permissions}}) { + const permissionsSet = new Set(permissions); + for (const toggle of this._toggles) { + const {permissionsSetting} = toggle.dataset; + const hasPermissions = this._hasAll(permissionsSet, this._getRequiredPermissions(toggle)); + + if (typeof permissionsSetting === 'string') { + const valid = !toggle.checked || hasPermissions; + this._setToggleValid(toggle, valid); + } else { + toggle.checked = hasPermissions; + } + } + } + + _setToggleValid(toggle, valid) { + const relative = toggle.closest('.settings-item'); + if (relative === null) { return; } + relative.dataset.invalid = `${!valid}`; + } + + async _updateValidity() { + const permissions = await this._settingsController.permissionsUtil.getAllPermissions(); + this._onPermissionsChanged({permissions}); + } + + _hasAll(set, values) { + for (const value of values) { + if (!set.has(value)) { return false; } + } + return true; + } + + _getRequiredPermissions(toggle) { + const requiredPermissions = toggle.dataset.requiredPermissions; + return (typeof requiredPermissions === 'string' && requiredPermissions.length > 0 ? requiredPermissions.split(' ') : []); + } +} diff --git a/ext/js/pages/settings/pitch-accents-preview-main.js b/ext/js/pages/settings/pitch-accents-preview-main.js new file mode 100644 index 00000000..d9d56727 --- /dev/null +++ b/ext/js/pages/settings/pitch-accents-preview-main.js @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * DisplayGenerator + */ + +(async () => { + try { + await yomichan.prepare(); + + const displayGenerator = new DisplayGenerator({ + japaneseUtil: null, + mediaLoader: null + }); + await displayGenerator.prepare(); + displayGenerator.preparePitchAccents(); + } catch (e) { + log.error(e); + } +})(); diff --git a/ext/js/pages/settings/popup-preview-controller.js b/ext/js/pages/settings/popup-preview-controller.js new file mode 100644 index 00000000..f98b0679 --- /dev/null +++ b/ext/js/pages/settings/popup-preview-controller.js @@ -0,0 +1,129 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * wanakana + */ + +class PopupPreviewController { + constructor(settingsController) { + this._settingsController = settingsController; + this._previewVisible = false; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + this._frame = null; + this._previewTextInput = null; + this._customCss = null; + this._customOuterCss = null; + this._previewFrameContainer = null; + } + + async prepare() { + const button = document.querySelector('#settings-popup-preview-button'); + if (button !== null) { + button.addEventListener('click', this._onShowPopupPreviewButtonClick.bind(this), false); + } else { + 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._customCss.addEventListener('input', this._onCustomCssChange.bind(this), false); + this._customCss.addEventListener('settingChanged', this._onCustomCssChange.bind(this), false); + this._customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false); + this._customOuterCss.addEventListener('settingChanged', this._onCustomOuterCssChange.bind(this), false); + this._frame.addEventListener('load', this._onFrameLoad2.bind(this), false); + this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this)); + } + } + + // Private + + _onShowPopupPreviewButtonClick() { + if (this._previewVisible) { return; } + this._showAppearancePreview(); + this._previewVisible = true; + } + + _showAppearancePreview() { + const container = document.querySelector('#settings-popup-preview-container'); + const buttonContainer = document.querySelector('#settings-popup-preview-button-container'); + const settings = document.querySelector('#settings-popup-preview-settings'); + const text = document.querySelector('#settings-popup-preview-text'); + const customCss = document.querySelector('#custom-popup-css'); + const customOuterCss = document.querySelector('#custom-popup-outer-css'); + const frame = document.createElement('iframe'); + + this._previewTextInput = text; + this._frame = frame; + this._customCss = customCss; + this._customOuterCss = customOuterCss; + + wanakana.bind(text); + + frame.addEventListener('load', this._onFrameLoad.bind(this), false); + text.addEventListener('input', this._onTextChange.bind(this), false); + customCss.addEventListener('input', this._onCustomCssChange.bind(this), false); + customOuterCss.addEventListener('input', this._onCustomOuterCssChange.bind(this), false); + this._settingsController.on('optionsContextChanged', this._onOptionsContextChange.bind(this)); + + frame.src = '/popup-preview.html'; + frame.id = 'settings-popup-preview-frame'; + + container.appendChild(frame); + if (buttonContainer.parentNode !== null) { + buttonContainer.parentNode.removeChild(buttonContainer); + } + settings.style.display = ''; + } + + _onFrameLoad() { + this._onOptionsContextChange(); + this._setText(this._previewTextInput.value); + } + + _onFrameLoad2() { + this._onOptionsContextChange(); + this._onCustomCssChange(); + this._onCustomOuterCssChange(); + } + + _onTextChange(e) { + this._setText(e.currentTarget.value); + } + + _onCustomCssChange() { + this._invoke('setCustomCss', {css: this._customCss.value}); + } + + _onCustomOuterCssChange() { + this._invoke('setCustomOuterCss', {css: this._customOuterCss.value}); + } + + _onOptionsContextChange() { + const optionsContext = this._settingsController.getOptionsContext(); + this._invoke('updateOptionsContext', {optionsContext}); + } + + _setText(text) { + this._invoke('setText', {text}); + } + + _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 new file mode 100644 index 00000000..80e248be --- /dev/null +++ b/ext/js/pages/settings/popup-preview-frame-main.js @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * HotkeyHandler + * PopupFactory + * PopupPreviewFrame + */ + +(async () => { + try { + await yomichan.prepare(); + + const {tabId, frameId} = await yomichan.api.frameInformationGet(); + + const hotkeyHandler = new HotkeyHandler(); + hotkeyHandler.prepare(); + + const popupFactory = new PopupFactory(frameId); + popupFactory.prepare(); + + const preview = new PopupPreviewFrame(tabId, frameId, popupFactory, hotkeyHandler); + await preview.prepare(); + + document.documentElement.dataset.loaded = 'true'; + } catch (e) { + log.error(e); + } +})(); diff --git a/ext/js/pages/settings/popup-preview-frame.js b/ext/js/pages/settings/popup-preview-frame.js new file mode 100644 index 00000000..638dd414 --- /dev/null +++ b/ext/js/pages/settings/popup-preview-frame.js @@ -0,0 +1,232 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * Frontend + * TextSourceRange + * wanakana + */ + +class PopupPreviewFrame { + constructor(tabId, frameId, popupFactory, hotkeyHandler) { + this._tabId = tabId; + this._frameId = frameId; + this._popupFactory = popupFactory; + this._hotkeyHandler = hotkeyHandler; + this._frontend = null; + this._apiOptionsGetOld = null; + this._popupShown = false; + this._themeChangeTimeout = null; + this._textSource = null; + this._optionsContext = null; + this._exampleText = null; + this._exampleTextInput = null; + this._targetOrigin = chrome.runtime.getURL('/').replace(/\/$/, ''); + + this._windowMessageHandlers = new Map([ + ['setText', this._onSetText.bind(this)], + ['setCustomCss', this._setCustomCss.bind(this)], + ['setCustomOuterCss', this._setCustomOuterCss.bind(this)], + ['updateOptionsContext', this._updateOptionsContext.bind(this)] + ]); + } + + async prepare() { + this._exampleText = document.querySelector('#example-text'); + this._exampleTextInput = document.querySelector('#example-text-input'); + + if (this._exampleTextInput !== null && typeof wanakana !== 'undefined') { + wanakana.bind(this._exampleTextInput); + } + + window.addEventListener('message', this._onMessage.bind(this), false); + + // Setup events + document.querySelector('#theme-dark-checkbox').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 + this._apiOptionsGetOld = yomichan.api.optionsGet.bind(yomichan.api); + yomichan.api.optionsGet = this._apiOptionsGet.bind(this); + + // Overwrite frontend + this._frontend = new Frontend({ + tabId: this._tabId, + frameId: this._frameId, + popupFactory: this._popupFactory, + depth: 0, + parentPopupId: null, + parentFrameId: null, + useProxyPopup: false, + canUseWindowPopup: false, + pageType: 'web', + allowRootFramePopupProxy: false, + childrenSupported: false, + hotkeyHandler: this._hotkeyHandler + }); + this._frontend.setOptionsContextOverride(this._optionsContext); + await this._frontend.prepare(); + this._frontend.setDisabledOverride(true); + this._frontend.canClearSelection = false; + this._frontend.popup.on('customOuterCssChanged', this._onCustomOuterCssChanged.bind(this)); + + // Update search + this._updateSearch(); + } + + // Private + + async _apiOptionsGet(...args) { + const options = await this._apiOptionsGetOld(...args); + options.general.enable = true; + options.general.debugInfo = false; + options.general.popupWidth = 400; + options.general.popupHeight = 250; + options.general.popupHorizontalOffset = 0; + options.general.popupVerticalOffset = 10; + options.general.popupHorizontalOffset2 = 10; + options.general.popupVerticalOffset2 = 0; + options.general.popupHorizontalTextPosition = 'below'; + options.general.popupVerticalTextPosition = 'before'; + options.scanning.selectText = false; + return options; + } + + _onCustomOuterCssChanged({node, inShadow}) { + if (node === null || inShadow) { return; } + + const node2 = document.querySelector('#popup-outer-css'); + if (node2 === null) { return; } + + // This simulates the stylesheet priorities when injecting using the web extension API. + node2.parentNode.insertBefore(node, node2); + } + + _onMessage(e) { + if (e.origin !== this._targetOrigin) { return; } + + const {action, params} = e.data; + const handler = this._windowMessageHandlers.get(action); + if (typeof handler !== 'function') { return; } + + handler(params); + } + + _onThemeDarkCheckboxChanged(e) { + document.documentElement.classList.toggle('dark', e.target.checked); + if (this._themeChangeTimeout !== null) { + clearTimeout(this._themeChangeTimeout); + } + this._themeChangeTimeout = setTimeout(() => { + this._themeChangeTimeout = null; + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.updateTheme(); + }, 300); + } + + _onExampleTextClick() { + if (this._exampleTextInput === null) { return; } + const visible = this._exampleTextInput.hidden; + this._exampleTextInput.hidden = !visible; + if (!visible) { return; } + this._exampleTextInput.focus(); + this._exampleTextInput.select(); + } + + _onExampleTextInputBlur() { + if (this._exampleTextInput === null) { return; } + this._exampleTextInput.hidden = true; + } + + _onExampleTextInputInput(e) { + this._setText(e.currentTarget.value); + } + + _onSetText({text}) { + this._setText(text, true); + } + + _setText(text, setInput) { + if (setInput && this._exampleTextInput !== null) { + this._exampleTextInput.value = text; + } + + if (this._exampleText === null) { return; } + + this._exampleText.textContent = text; + if (this._frontend === null) { return; } + this._updateSearch(); + } + + _setInfoVisible(visible) { + const node = document.querySelector('.placeholder-info'); + if (node === null) { return; } + + node.classList.toggle('placeholder-info-visible', visible); + } + + _setCustomCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomCss(css); + } + + _setCustomOuterCss({css}) { + if (this._frontend === null) { return; } + const popup = this._frontend.popup; + if (popup === null) { return; } + popup.setCustomOuterCss(css, false); + } + + async _updateOptionsContext({optionsContext}) { + this._optionsContext = optionsContext; + if (this._frontend === null) { return; } + this._frontend.setOptionsContextOverride(optionsContext); + await this._frontend.updateOptions(); + await this._updateSearch(); + } + + async _updateSearch() { + if (this._exampleText === null) { return; } + + const textNode = this._exampleText.firstChild; + if (textNode === null) { return; } + + const range = document.createRange(); + range.selectNodeContents(textNode); + const source = new TextSourceRange(range, range.toString(), null, null); + + try { + await this._frontend.setTextSource(source); + } finally { + source.cleanup(); + } + this._textSource = source; + await this._frontend.showContentCompleted(); + + const popup = this._frontend.popup; + if (popup !== null && popup.isVisibleSync()) { + this._popupShown = true; + } + + this._setInfoVisible(!this._popupShown); + } +} diff --git a/ext/js/pages/settings/popup-window-controller.js b/ext/js/pages/settings/popup-window-controller.js new file mode 100644 index 00000000..403c060c --- /dev/null +++ b/ext/js/pages/settings/popup-window-controller.js @@ -0,0 +1,34 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class PopupWindowController { + prepare() { + const testLink = document.querySelector('#test-window-open-link'); + testLink.addEventListener('click', this._onTestWindowOpenLinkClick.bind(this), false); + } + + // Private + + _onTestWindowOpenLinkClick(e) { + e.preventDefault(); + this._testWindowOpen(); + } + + async _testWindowOpen() { + await yomichan.api.getOrCreateSearchPopup({focus: true}); + } +} diff --git a/ext/js/pages/settings/profile-conditions-ui.js b/ext/js/pages/settings/profile-conditions-ui.js new file mode 100644 index 00000000..5fda1dc0 --- /dev/null +++ b/ext/js/pages/settings/profile-conditions-ui.js @@ -0,0 +1,712 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * KeyboardMouseInputField + */ + +class ProfileConditionsUI extends EventDispatcher { + constructor(settingsController) { + super(); + this._settingsController = settingsController; + this._os = null; + this._conditionGroupsContainer = null; + this._addConditionGroupButton = null; + this._children = []; + this._eventListeners = new EventListenerCollection(); + this._defaultType = 'popupLevel'; + this._profileIndex = 0; + this._descriptors = new Map([ + [ + 'popupLevel', + { + displayName: 'Popup Level', + defaultOperator: 'equal', + operators: new Map([ + ['equal', {displayName: '=', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['notEqual', {displayName: '\u2260', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['lessThan', {displayName: '<', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['greaterThan', {displayName: '>', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['lessThanOrEqual', {displayName: '\u2264', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}], + ['greaterThanOrEqual', {displayName: '\u2265', type: 'integer', defaultValue: '0', validate: this._validateInteger.bind(this), normalize: this._normalizeInteger.bind(this)}] + ]) + } + ], + [ + 'url', + { + displayName: 'URL', + defaultOperator: 'matchDomain', + operators: new Map([ + ['matchDomain', {displayName: 'Matches Domain', type: 'string', defaultValue: 'example.com', resetDefaultOnChange: true, validate: this._validateDomains.bind(this), normalize: this._normalizeDomains.bind(this)}], + ['matchRegExp', {displayName: 'Matches RegExp', type: 'string', defaultValue: 'example\\.com', resetDefaultOnChange: true, validate: this._validateRegExp.bind(this)}] + ]) + } + ], + [ + 'modifierKeys', + { + displayName: 'Modifier Keys', + defaultOperator: 'are', + operators: new Map([ + ['are', {displayName: 'Are', type: 'modifierKeys', defaultValue: ''}], + ['areNot', {displayName: 'Are Not', type: 'modifierKeys', defaultValue: ''}], + ['include', {displayName: 'Include', type: 'modifierKeys', defaultValue: ''}], + ['notInclude', {displayName: 'Don\'t Include', type: 'modifierKeys', defaultValue: ''}] + ]) + } + ] + ]); + } + + get settingsController() { + return this._settingsController; + } + + get profileIndex() { + return this._profileIndex; + } + + get os() { + return this._os; + } + + set os(value) { + this._os = value; + } + + async prepare(profileIndex) { + const options = await this._settingsController.getOptionsFull(); + const {profiles} = options; + if (profileIndex < 0 || profileIndex >= profiles.length) { return; } + const {conditionGroups} = profiles[profileIndex]; + + this._profileIndex = profileIndex; + this._conditionGroupsContainer = document.querySelector('#profile-condition-groups'); + this._addConditionGroupButton = document.querySelector('#profile-add-condition-group'); + + for (let i = 0, ii = conditionGroups.length; i < ii; ++i) { + this._addConditionGroup(conditionGroups[i], i); + } + + this._eventListeners.addEventListener(this._addConditionGroupButton, 'click', this._onAddConditionGroupButtonClick.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + + for (const child of this._children) { + child.cleanup(); + } + this._children = []; + + this._conditionGroupsContainer = null; + this._addConditionGroupButton = null; + } + + instantiateTemplate(names) { + return this._settingsController.instantiateTemplate(names); + } + + getDescriptorTypes() { + const results = []; + for (const [name, {displayName}] of this._descriptors.entries()) { + results.push({name, displayName}); + } + return results; + } + + getDescriptorOperators(type) { + const info = this._descriptors.get(type); + const results = []; + if (typeof info !== 'undefined') { + for (const [name, {displayName}] of info.operators.entries()) { + results.push({name, displayName}); + } + } + return results; + } + + getDefaultType() { + return this._defaultType; + } + + getDefaultOperator(type) { + const info = this._descriptors.get(type); + return (typeof info !== 'undefined' ? info.defaultOperator : ''); + } + + getOperatorDetails(type, operator) { + const info = this._getOperatorDetails(type, operator); + + const { + displayName=operator, + type: type2='string', + defaultValue='', + resetDefaultOnChange=false, + validate=null, + normalize=null + } = (typeof info === 'undefined' ? {} : info); + + return { + displayName, + type: type2, + defaultValue, + resetDefaultOnChange, + validate, + normalize + }; + } + + getDefaultCondition() { + const type = this.getDefaultType(); + const operator = this.getDefaultOperator(type); + const {defaultValue: value} = this.getOperatorDetails(type, operator); + return {type, operator, value}; + } + + removeConditionGroup(child) { + const index = child.index; + if (index < 0 || index >= this._children.length) { return false; } + + const child2 = this._children[index]; + if (child !== child2) { return false; } + + this._children.splice(index, 1); + child.cleanup(); + + for (let i = index, ii = this._children.length; i < ii; ++i) { + this._children[i].index = i; + } + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditionGroups'), + start: index, + deleteCount: 1, + items: [] + }]); + + this._triggerConditionGroupCountChanged(this._children.length); + + return true; + } + + splitValue(value) { + return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + } + + getPath(property) { + property = (typeof property === 'string' ? `.${property}` : ''); + return `profiles[${this.profileIndex}]${property}`; + } + + createKeyboardMouseInputField(inputNode, mouseButton) { + return new KeyboardMouseInputField(inputNode, mouseButton, this._os); + } + + // Private + + _onAddConditionGroupButtonClick() { + const conditionGroup = { + conditions: [this.getDefaultCondition()] + }; + const index = this._children.length; + + this._addConditionGroup(conditionGroup, index); + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditionGroups'), + start: index, + deleteCount: 0, + items: [conditionGroup] + }]); + + this._triggerConditionGroupCountChanged(this._children.length); + } + + _addConditionGroup(conditionGroup, index) { + const child = new ProfileConditionGroupUI(this, index); + child.prepare(conditionGroup); + this._children.push(child); + this._conditionGroupsContainer.appendChild(child.node); + return child; + } + + _getOperatorDetails(type, operator) { + const info = this._descriptors.get(type); + return (typeof info !== 'undefined' ? info.operators.get(operator) : void 0); + } + + _validateInteger(value) { + const number = Number.parseFloat(value); + return Number.isFinite(number) && Math.floor(number) === number; + } + + _validateDomains(value) { + return this.splitValue(value).length > 0; + } + + _validateRegExp(value) { + try { + new RegExp(value, 'i'); + return true; + } catch (e) { + return false; + } + } + + _normalizeInteger(value) { + const number = Number.parseFloat(value); + return `${number}`; + } + + _normalizeDomains(value) { + return this.splitValue(value).join(', '); + } + + _triggerConditionGroupCountChanged(count) { + this.trigger('conditionGroupCountChanged', {count, profileIndex: this._profileIndex}); + } +} + +class ProfileConditionGroupUI { + constructor(parent, index) { + this._parent = parent; + this._index = index; + this._node = null; + this._conditionContainer = null; + this._addConditionButton = null; + this._children = []; + this._eventListeners = new EventListenerCollection(); + } + + get settingsController() { + return this._parent.settingsController; + } + + get parent() { + return this._parent; + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + } + + get node() { + return this._node; + } + + get childCount() { + return this._children.length; + } + + 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); + } + + this._eventListeners.addEventListener(this._addConditionButton, 'click', this._onAddConditionButtonClick.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + + for (const child of this._children) { + child.cleanup(); + } + this._children = []; + + 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); + } + } + + removeCondition(child) { + const index = child.index; + if (index < 0 || index >= this._children.length) { return false; } + + const child2 = this._children[index]; + if (child !== child2) { return false; } + + this._children.splice(index, 1); + child.cleanup(); + + for (let i = index, ii = this._children.length; i < ii; ++i) { + this._children[i].index = i; + } + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditions'), + start: index, + deleteCount: 1, + items: [] + }]); + + if (this._children.length === 0) { + this.removeSelf(); + } + + return true; + } + + 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; + + this._addCondition(condition, index); + + this.settingsController.modifyGlobalSettings([{ + action: 'splice', + path: this.getPath('conditions'), + start: index, + deleteCount: 0, + items: [condition] + }]); + } + + _addCondition(condition, index) { + const child = new ProfileConditionUI(this, index); + child.prepare(condition); + this._children.push(child); + this._conditionContainer.appendChild(child.node); + return child; + } +} + +class ProfileConditionUI { + constructor(parent, index) { + this._parent = parent; + 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; + this._value = ''; + this._kbmInputField = null; + this._eventListeners = new EventListenerCollection(); + this._inputEventListeners = new EventListenerCollection(); + } + + get settingsController() { + return this._parent.parent.settingsController; + } + + get parent() { + return this._parent; + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + } + + get node() { + return this._node; + } + + 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); + this._updateValueInput(value, operatorDetails); + + this._eventListeners.addEventListener(this._typeInput, 'change', this._onTypeChange.bind(this), false); + this._eventListeners.addEventListener(this._operatorInput, 'change', this._onOperatorChange.bind(this), false); + if (this._removeButton !== null) { this._eventListeners.addEventListener(this._removeButton, 'click', this._onRemoveButtonClick.bind(this), false); } + if (this._menuButton !== null) { + this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); + this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false); + } + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + this._value = ''; + + 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); + } + } + + getPath(property) { + property = (typeof property === 'string' ? `.${property}` : ''); + return this._parent.getPath(`conditions[${this._index}]${property}`); + } + + // Private + + _onTypeChange(e) { + const type = e.currentTarget.value; + this._setType(type); + } + + _onOperatorChange(e) { + const type = this._typeInput.value; + const operator = e.currentTarget.value; + this._setOperator(type, operator); + } + + _onValueInputChange({validate, normalize}, e) { + const node = e.currentTarget; + const value = node.value; + const okay = this._validateValue(value, validate); + this._value = value; + if (okay) { + const normalizedValue = this._normalizeValue(value, normalize); + node.value = normalizedValue; + this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue); + } + } + + _onModifierInputChange({validate, normalize}, {modifiers}) { + modifiers = this._joinModifiers(modifiers); + const okay = this._validateValue(modifiers, validate); + this._value = modifiers; + if (okay) { + const normalizedValue = this._normalizeValue(modifiers, normalize); + this.settingsController.setGlobalSetting(this.getPath('value'), normalizedValue); + } + } + + _onRemoveButtonClick() { + this._removeSelf(); + } + + _onMenuOpen(e) { + const bodyNode = e.detail.menu.bodyNode; + const deleteGroup = bodyNode.querySelector('.popup-menu-item[data-menu-action="deleteGroup"]'); + if (deleteGroup !== null) { + deleteGroup.hidden = (this._parent.childCount <= 1); + } + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'delete': + this._removeSelf(); + break; + case 'deleteGroup': + this._parent.removeSelf(); + break; + case 'resetValue': + this._resetValue(); + break; + } + } + + _getDescriptorTypes() { + return this._parent.parent.getDescriptorTypes(); + } + + _getDescriptorOperators(type) { + return this._parent.parent.getDescriptorOperators(type); + } + + _getOperatorDetails(type, operator) { + return this._parent.parent.getOperatorDetails(type, operator); + } + + _updateTypes(type) { + const types = this._getDescriptorTypes(); + this._updateSelect(this._typeInput, this._typeOptionContainer, types, type); + } + + _updateOperators(type, operator) { + const operators = this._getDescriptorOperators(type); + this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator); + } + + _updateSelect(select, optionContainer, values, value) { + optionContainer.textContent = ''; + for (const {name, displayName} of values) { + const option = document.createElement('option'); + option.value = name; + option.textContent = displayName; + optionContainer.appendChild(option); + } + select.value = value; + } + + _updateValueInput(value, {type, validate, normalize}) { + this._inputEventListeners.removeAllEventListeners(); + if (this._kbmInputField !== null) { + this._kbmInputField.cleanup(); + this._kbmInputField = null; + } + + let inputType = 'text'; + let inputValue = value; + let inputStep = null; + let showMouseButton = false; + const events = []; + const inputData = {validate, normalize}; + const node = this._valueInput; + + switch (type) { + case 'integer': + inputType = 'number'; + inputStep = '1'; + events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]); + break; + case 'modifierKeys': + case 'modifierInputs': + inputValue = null; + 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]); + break; + default: // 'string' + events.push(['addEventListener', node, 'change', this._onValueInputChange.bind(this, inputData), false]); + break; + } + + this._value = value; + delete node.dataset.invalid; + node.type = inputType; + if (inputValue !== null) { + node.value = inputValue; + } + if (typeof inputStep === 'string') { + node.step = inputStep; + } else { + node.removeAttribute('step'); + } + this._mouseButtonContainer.hidden = !showMouseButton; + for (const args of events) { + this._inputEventListeners.addGeneric(...args); + } + + this._validateValue(value, validate); + } + + _validateValue(value, validate) { + const okay = (validate === null || validate(value)); + this._valueInput.dataset.invalid = `${!okay}`; + return okay; + } + + _normalizeValue(value, normalize) { + return (normalize !== null ? normalize(value) : value); + } + + _removeSelf() { + this._parent.removeCondition(this); + } + + _splitModifiers(modifiersString) { + return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + } + + _joinModifiers(modifiersArray) { + return modifiersArray.join(', '); + } + + async _setType(type, operator) { + const operators = this._getDescriptorOperators(type); + if (typeof operator === 'undefined') { + operator = operators.length > 0 ? operators[0].name : ''; + } + const operatorDetails = this._getOperatorDetails(type, operator); + const {defaultValue} = operatorDetails; + this._updateSelect(this._operatorInput, this._operatorOptionContainer, operators, operator); + this._updateValueInput(defaultValue, operatorDetails); + await this.settingsController.modifyGlobalSettings([ + {action: 'set', path: this.getPath('type'), value: type}, + {action: 'set', path: this.getPath('operator'), value: operator}, + {action: 'set', path: this.getPath('value'), value: defaultValue} + ]); + } + + async _setOperator(type, operator) { + const operatorDetails = this._getOperatorDetails(type, operator); + const settingsModifications = [{action: 'set', path: this.getPath('operator'), value: operator}]; + if (operatorDetails.resetDefaultOnChange) { + const {defaultValue} = operatorDetails; + const okay = this._updateValueInput(defaultValue, operatorDetails); + if (okay) { + settingsModifications.push({action: 'set', path: this.getPath('value'), value: defaultValue}); + } + } + await this.settingsController.modifyGlobalSettings(settingsModifications); + } + + async _resetValue() { + const type = this._typeInput.value; + 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 new file mode 100644 index 00000000..3883e80a --- /dev/null +++ b/ext/js/pages/settings/profile-controller.js @@ -0,0 +1,697 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * ProfileConditionsUI + */ + +class ProfileController { + constructor(settingsController, modalController) { + this._settingsController = settingsController; + this._modalController = modalController; + this._profileConditionsUI = new ProfileConditionsUI(settingsController); + this._profileConditionsIndex = null; + this._profileActiveSelect = null; + this._profileTargetSelect = null; + this._profileCopySourceSelect = null; + this._profileNameInput = null; + this._removeProfileNameElement = null; + this._profileAddButton = null; + this._profileRemoveButton = null; + this._profileRemoveConfirmButton = null; + this._profileCopyButton = null; + this._profileCopyConfirmButton = null; + this._profileMoveUpButton = null; + this._profileMoveDownButton = null; + this._profileEntryListContainer = null; + this._profileConditionsProfileName = null; + this._profileRemoveModal = null; + this._profileCopyModal = null; + this._profileConditionsModal = null; + this._profileEntriesSupported = false; + this._profileEntryList = []; + this._profiles = []; + this._profileCurrent = 0; + } + + get profileCount() { + return this._profiles.length; + } + + get profileCurrentIndex() { + return this._profileCurrent; + } + + async prepare() { + const {platform: {os}} = await yomichan.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._profileNameInput = document.querySelector('#profile-name-input'); + this._removeProfileNameElement = document.querySelector('#profile-remove-name'); + this._profileAddButton = document.querySelector('#profile-add-button'); + this._profileRemoveButton = document.querySelector('#profile-remove-button'); + this._profileRemoveConfirmButton = document.querySelector('#profile-remove-confirm-button'); + this._profileCopyButton = document.querySelector('#profile-copy-button'); + this._profileCopyConfirmButton = document.querySelector('#profile-copy-confirm-button'); + this._profileMoveUpButton = document.querySelector('#profile-move-up-button'); + this._profileMoveDownButton = document.querySelector('#profile-move-down-button'); + this._profileEntryListContainer = document.querySelector('#profile-entry-list'); + this._profileConditionsProfileName = 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'); + + this._profileEntriesSupported = (this._profileEntryListContainer !== null); + + if (this._profileActiveSelect !== null) { this._profileActiveSelect.addEventListener('change', this._onProfileActiveChange.bind(this), false); } + if (this._profileTargetSelect !== null) { this._profileTargetSelect.addEventListener('change', this._onProfileTargetChange.bind(this), false); } + if (this._profileNameInput !== null) { this._profileNameInput.addEventListener('change', this._onNameChanged.bind(this), false); } + if (this._profileAddButton !== null) { this._profileAddButton.addEventListener('click', this._onAdd.bind(this), false); } + if (this._profileRemoveButton !== null) { this._profileRemoveButton.addEventListener('click', this._onDelete.bind(this), false); } + if (this._profileRemoveConfirmButton !== null) { this._profileRemoveConfirmButton.addEventListener('click', this._onDeleteConfirm.bind(this), false); } + if (this._profileCopyButton !== null) { this._profileCopyButton.addEventListener('click', this._onCopy.bind(this), false); } + if (this._profileCopyConfirmButton !== null) { this._profileCopyConfirmButton.addEventListener('click', this._onCopyConfirm.bind(this), false); } + if (this._profileMoveUpButton !== null) { this._profileMoveUpButton.addEventListener('click', this._onMove.bind(this, -1), false); } + if (this._profileMoveDownButton !== null) { this._profileMoveDownButton.addEventListener('click', this._onMove.bind(this, 1), false); } + + this._profileConditionsUI.on('conditionGroupCountChanged', this._onConditionGroupCountChanged.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + this._onOptionsChanged(); + } + + async moveProfile(profileIndex, offset) { + if (this._getProfile(profileIndex) === null) { return; } + + const profileIndexNew = Math.max(0, Math.min(this._profiles.length - 1, profileIndex + offset)); + if (profileIndex === profileIndexNew) { return; } + + await this.swapProfiles(profileIndex, profileIndexNew); + } + + async setProfileName(profileIndex, value) { + const profile = this._getProfile(profileIndex); + if (profile === null) { return; } + + profile.name = value; + this._updateSelectName(profileIndex, value); + + const profileEntry = this._getProfileEntry(profileIndex); + if (profileEntry !== null) { profileEntry.setName(value); } + + await this._settingsController.setGlobalSetting(`profiles[${profileIndex}].name`, value); + } + + async setDefaultProfile(profileIndex) { + const profile = this._getProfile(profileIndex); + if (profile === null) { return; } + + this._profileActiveSelect.value = `${profileIndex}`; + this._profileCurrent = profileIndex; + + const profileEntry = this._getProfileEntry(profileIndex); + if (profileEntry !== null) { profileEntry.setIsDefault(true); } + + await this._settingsController.setGlobalSetting('profileCurrent', profileIndex); + } + + async copyProfile(sourceProfileIndex, destinationProfileIndex) { + const sourceProfile = this._getProfile(sourceProfileIndex); + if (sourceProfile === null || !this._getProfile(destinationProfileIndex)) { return; } + + const options = clone(sourceProfile.options); + this._profiles[destinationProfileIndex].options = options; + + this._updateProfileSelectOptions(); + + const destinationProfileEntry = this._getProfileEntry(destinationProfileIndex); + if (destinationProfileEntry !== null) { + destinationProfileEntry.updateState(); + } + + await this._settingsController.modifyGlobalSettings([{ + action: 'set', + path: `profiles[${destinationProfileIndex}].options`, + value: options + }]); + + await this._settingsController.refresh(); + } + + async duplicateProfile(profileIndex) { + const profile = this._getProfile(profileIndex); + if (this.profile === null) { return; } + + // Create new profile + const newProfile = clone(profile); + newProfile.name = this._createCopyName(profile.name, this._profiles, 100); + + // Update state + const index = this._profiles.length; + this._profiles.push(newProfile); + if (this._profileEntriesSupported) { + this._addProfileEntry(index); + } + this._updateProfileSelectOptions(); + + // Modify settings + await this._settingsController.modifyGlobalSettings([{ + action: 'splice', + path: 'profiles', + start: index, + deleteCount: 0, + items: [newProfile] + }]); + + // Update profile index + this._settingsController.profileIndex = index; + } + + async deleteProfile(profileIndex) { + const profile = this._getProfile(profileIndex); + if (profile === null || this.profileCount <= 1) { return; } + + // Get indices + let profileCurrentNew = this._profileCurrent; + const settingsProfileIndex = this._settingsController.profileIndex; + + // Construct settings modifications + const modifications = [{ + action: 'splice', + path: 'profiles', + start: profileIndex, + deleteCount: 1, + items: [] + }]; + if (profileCurrentNew >= profileIndex) { + profileCurrentNew = Math.min(profileCurrentNew - 1, this._profiles.length - 1); + modifications.push({ + action: 'set', + path: 'profileCurrent', + value: profileCurrentNew + }); + } + + // Update state + this._profileCurrent = profileCurrentNew; + + this._profiles.splice(profileIndex, 1); + + if (profileIndex < this._profileEntryList.length) { + const profileEntry = this._profileEntryList[profileIndex]; + profileEntry.cleanup(); + this._profileEntryList.splice(profileIndex, 1); + + for (let i = profileIndex, ii = this._profileEntryList.length; i < ii; ++i) { + this._profileEntryList[i].index = i; + } + } + + const profileEntry2 = this._getProfileEntry(profileCurrentNew); + if (profileEntry2 !== null) { + profileEntry2.setIsDefault(true); + } + + this._updateProfileSelectOptions(); + + // Modify settings + await this._settingsController.modifyGlobalSettings(modifications); + + // Update profile index + if (settingsProfileIndex === profileIndex) { + this._settingsController.profileIndex = profileCurrentNew; + } + } + + async swapProfiles(index1, index2) { + const profile1 = this._getProfile(index1); + const profile2 = this._getProfile(index2); + if (profile1 === null || profile2 === null || index1 === index2) { return; } + + // Get swapped indices + const profileCurrent = this._profileCurrent; + const profileCurrentNew = this._getSwappedValue(profileCurrent, index1, index2); + + const settingsProfileIndex = this._settingsController.profileIndex; + const settingsProfileIndexNew = this._getSwappedValue(settingsProfileIndex, index1, index2); + + // Construct settings modifications + const modifications = [{ + action: 'swap', + path1: `profiles[${index1}]`, + path2: `profiles[${index2}]` + }]; + if (profileCurrentNew !== profileCurrent) { + modifications.push({ + action: 'set', + path: 'profileCurrent', + value: profileCurrentNew + }); + } + + // Update state + this._profileCurrent = profileCurrentNew; + + this._profiles[index1] = profile2; + this._profiles[index2] = profile1; + + const entry1 = this._getProfileEntry(index1); + const entry2 = this._getProfileEntry(index2); + if (entry1 !== null && entry2 !== null) { + entry1.index = index2; + entry2.index = index1; + this._swapDomNodes(entry1.node, entry2.node); + this._profileEntryList[index1] = entry2; + this._profileEntryList[index2] = entry1; + } + + this._updateProfileSelectOptions(); + + // Modify settings + await this._settingsController.modifyGlobalSettings(modifications); + + // Update profile index + if (settingsProfileIndex !== settingsProfileIndexNew) { + this._settingsController.profileIndex = settingsProfileIndexNew; + } + } + + 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); + } + + openCopyProfileModal(profileIndex) { + const profile = this._getProfile(profileIndex); + if (profile === null || this.profileCount <= 1) { return; } + + let copyFromIndex = this._profileCurrent; + if (copyFromIndex === profileIndex) { + if (profileIndex !== 0) { + copyFromIndex = 0; + } else if (this.profileCount > 1) { + copyFromIndex = 1; + } + } + + const profileIndexString = `${profileIndex}`; + for (const option of this._profileCopySourceSelect.querySelectorAll('option')) { + const {value} = option; + option.disabled = (value === profileIndexString); + } + this._profileCopySourceSelect.value = `${copyFromIndex}`; + + this._profileCopyModal.node.dataset.profileIndex = `${profileIndex}`; + this._profileCopyModal.setVisible(true); + } + + openProfileConditionsModal(profileIndex) { + const profile = this._getProfile(profileIndex); + if (profile === null) { return; } + + if (this._profileConditionsModal === null) { return; } + this._profileConditionsModal.setVisible(true); + + this._profileConditionsUI.cleanup(); + this._profileConditionsIndex = profileIndex; + this._profileConditionsUI.prepare(profileIndex); + if (this._profileConditionsProfileName !== null) { + this._profileConditionsProfileName.textContent = profile.name; + } + } + + // Private + + async _onOptionsChanged() { + // Update state + const {profiles, profileCurrent} = await this._settingsController.getOptionsFull(); + this._profiles = profiles; + this._profileCurrent = profileCurrent; + + const settingsProfileIndex = this._settingsController.profileIndex; + const settingsProfile = this._getProfile(settingsProfileIndex); + + // Udpate UI + this._updateProfileSelectOptions(); + + this._profileActiveSelect.value = `${profileCurrent}`; + this._profileTargetSelect.value = `${settingsProfileIndex}`; + + if (this._profileRemoveButton !== null) { this._profileRemoveButton.disabled = (profiles.length <= 1); } + if (this._profileCopyButton !== null) { this._profileCopyButton.disabled = (profiles.length <= 1); } + if (this._profileMoveUpButton !== null) { this._profileMoveUpButton.disabled = (settingsProfileIndex <= 0); } + if (this._profileMoveDownButton !== null) { this._profileMoveDownButton.disabled = (settingsProfileIndex >= profiles.length - 1); } + + if (this._profileNameInput !== null && settingsProfile !== null) { this._profileNameInput.value = settingsProfile.name; } + + // Update profile conditions + this._profileConditionsUI.cleanup(); + const conditionsProfile = this._getProfile(this._profileConditionsIndex !== null ? this._profileConditionsIndex : settingsProfileIndex); + if (conditionsProfile !== null) { + this._profileConditionsUI.prepare(settingsProfileIndex); + } + + // Udpate profile entries + for (const entry of this._profileEntryList) { + entry.cleanup(); + } + this._profileEntryList = []; + if (this._profileEntriesSupported) { + for (let i = 0, ii = profiles.length; i < ii; ++i) { + this._addProfileEntry(i); + } + } + } + + _onProfileActiveChange(e) { + const value = this._tryGetValidProfileIndex(e.currentTarget.value); + if (value === null) { return; } + this.setDefaultProfile(value); + } + + _onProfileTargetChange(e) { + const value = this._tryGetValidProfileIndex(e.currentTarget.value); + if (value === null) { return; } + this._settingsController.profileIndex = value; + } + + _onNameChanged(e) { + this.setProfileName(this._settingsController.profileIndex, e.currentTarget.value); + } + + _onAdd() { + this.duplicateProfile(this._settingsController.profileIndex); + } + + _onDelete(e) { + const profileIndex = this._settingsController.profileIndex; + if (e.shiftKey) { + this.deleteProfile(profileIndex); + } else { + this.openDeleteProfileModal(profileIndex); + } + } + + _onDeleteConfirm() { + const modal = this._profileRemoveModal; + modal.setVisible(false); + const {node} = modal; + let profileIndex = node.dataset.profileIndex; + delete node.dataset.profileIndex; + + profileIndex = this._tryGetValidProfileIndex(profileIndex); + if (profileIndex === null) { return; } + + this.deleteProfile(profileIndex); + } + + _onCopy() { + this.openCopyProfileModal(this._settingsController.profileIndex); + } + + _onCopyConfirm() { + const modal = this._profileCopyModal; + modal.setVisible(false); + const {node} = modal; + let destinationProfileIndex = node.dataset.profileIndex; + delete node.dataset.profileIndex; + + destinationProfileIndex = this._tryGetValidProfileIndex(destinationProfileIndex); + if (destinationProfileIndex === null) { return; } + + const sourceProfileIndex = this._tryGetValidProfileIndex(this._profileCopySourceSelect.value); + if (sourceProfileIndex === null) { return; } + + this.copyProfile(sourceProfileIndex, destinationProfileIndex); + } + + _onMove(offset) { + this.moveProfile(this._settingsController.profileIndex, offset); + } + + _onConditionGroupCountChanged({count, profileIndex}) { + if (profileIndex >= 0 && profileIndex < this._profileEntryList.length) { + const profileEntry = this._profileEntryList[profileIndex]; + profileEntry.setConditionGroupsCount(count); + } + } + + _addProfileEntry(profileIndex) { + const profile = this._profiles[profileIndex]; + const node = this._settingsController.instantiateTemplate('profile-entry'); + const entry = new ProfileEntry(this, node); + this._profileEntryList.push(entry); + entry.prepare(profile, profileIndex); + this._profileEntryListContainer.appendChild(node); + } + + _updateProfileSelectOptions() { + for (const select of this._getAllProfileSelects()) { + const fragment = document.createDocumentFragment(); + for (let i = 0; i < this._profiles.length; ++i) { + const profile = this._profiles[i]; + const option = document.createElement('option'); + option.value = `${i}`; + option.textContent = profile.name; + fragment.appendChild(option); + } + select.textContent = ''; + select.appendChild(fragment); + } + } + + _updateSelectName(index, name) { + const optionValue = `${index}`; + for (const select of this._getAllProfileSelects()) { + for (const option of select.querySelectorAll('option')) { + if (option.value === optionValue) { + option.textContent = name; + } + } + } + } + + _getAllProfileSelects() { + return [ + this._profileActiveSelect, + this._profileTargetSelect, + this._profileCopySourceSelect + ]; + } + + _tryGetValidProfileIndex(stringValue) { + if (typeof stringValue !== 'string') { return null; } + const intValue = parseInt(stringValue, 10); + return ( + Number.isFinite(intValue) && + intValue >= 0 && + intValue < this.profileCount ? + intValue : null + ); + } + + _createCopyName(name, profiles, maxUniqueAttempts) { + let space, index, prefix, suffix; + const match = /^([\w\W]*\(Copy)((\s+)(\d+))?(\)\s*)$/.exec(name); + if (match === null) { + prefix = `${name} (Copy`; + space = ''; + index = ''; + suffix = ')'; + } else { + prefix = match[1]; + suffix = match[5]; + if (typeof match[2] === 'string') { + space = match[3]; + index = parseInt(match[4], 10) + 1; + } else { + space = ' '; + index = 2; + } + } + + let i = 0; + while (true) { + const newName = `${prefix}${space}${index}${suffix}`; + if (i++ >= maxUniqueAttempts || profiles.findIndex((profile) => profile.name === newName) < 0) { + return newName; + } + if (typeof index !== 'number') { + index = 2; + space = ' '; + } else { + ++index; + } + } + } + + _getSwappedValue(currentValue, value1, value2) { + if (currentValue === value1) { return value2; } + if (currentValue === value2) { return value1; } + return currentValue; + } + + _getProfile(profileIndex) { + return (profileIndex >= 0 && profileIndex < this._profiles.length ? this._profiles[profileIndex] : null); + } + + _getProfileEntry(profileIndex) { + return (profileIndex >= 0 && profileIndex < this._profileEntryList.length ? this._profileEntryList[profileIndex] : null); + } + + _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); } + } +} + +class ProfileEntry { + constructor(profileController, node) { + this._profileController = profileController; + this._node = node; + this._profile = null; + this._index = 0; + this._isDefaultRadio = null; + this._nameInput = null; + this._countLink = null; + this._countText = null; + this._menuButton = null; + this._eventListeners = new EventListenerCollection(); + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + } + + 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'); + + this.updateState(); + + this._eventListeners.addEventListener(this._isDefaultRadio, 'change', this._onIsDefaultRadioChange.bind(this), false); + this._eventListeners.addEventListener(this._nameInput, 'input', this._onNameInputInput.bind(this), false); + this._eventListeners.addEventListener(this._countLink, 'click', this._onConditionsCountLinkClick.bind(this), false); + this._eventListeners.addEventListener(this._menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); + this._eventListeners.addEventListener(this._menuButton, 'menuClose', this._onMenuClose.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + if (this._node.parentNode !== null) { + this._node.parentNode.removeChild(this._node); + } + } + + setName(value) { + if (this._nameInput.value === value) { return; } + this._nameInput.value = 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); + } + + setConditionGroupsCount(count) { + this._countText.textContent = `${count}`; + } + + // Private + + _onIsDefaultRadioChange(e) { + if (!e.currentTarget.checked) { return; } + this._profileController.setDefaultProfile(this._index); + } + + _onNameInputInput(e) { + const name = e.currentTarget.value; + this._profileController.setProfileName(this._index, name); + } + + _onConditionsCountLinkClick() { + this._profileController.openProfileConditionsModal(this._index); + } + + _onMenuOpen(e) { + const bodyNode = e.detail.menu.bodyNode; + const count = this._profileController.profileCount; + this._setMenuActionEnabled(bodyNode, 'moveUp', this._index > 0); + this._setMenuActionEnabled(bodyNode, 'moveDown', this._index < count - 1); + this._setMenuActionEnabled(bodyNode, 'copyFrom', count > 1); + this._setMenuActionEnabled(bodyNode, 'delete', count > 1); + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'moveUp': + this._profileController.moveProfile(this._index, -1); + break; + case 'moveDown': + this._profileController.moveProfile(this._index, 1); + break; + case 'copyFrom': + this._profileController.openCopyProfileModal(this._index); + break; + case 'editConditions': + this._profileController.openProfileConditionsModal(this._index); + break; + case 'duplicate': + this._profileController.duplicateProfile(this._index); + break; + case 'delete': + this._profileController.openDeleteProfileModal(this._index); + break; + } + } + + _setMenuActionEnabled(menu, action, enabled) { + const element = 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 new file mode 100644 index 00000000..79b2bdf4 --- /dev/null +++ b/ext/js/pages/settings/scan-inputs-controller.js @@ -0,0 +1,309 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * KeyboardMouseInputField + */ + +class ScanInputsController { + constructor(settingsController) { + this._settingsController = settingsController; + this._os = null; + this._container = null; + this._addButton = null; + this._scanningInputCountNodes = null; + this._entries = []; + } + + async prepare() { + const {platform: {os}} = await yomichan.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._addButton.addEventListener('click', this._onAddButtonClick.bind(this), false); + this._settingsController.on('scanInputsChanged', this._onScanInputsChanged.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + this.refresh(); + } + + removeInput(index) { + if (index < 0 || index >= this._entries.length) { return false; } + const input = this._entries[index]; + input.cleanup(); + this._entries.splice(index, 1); + for (let i = index, ii = this._entries.length; i < ii; ++i) { + this._entries[i].index = i; + } + this._updateCounts(); + this._modifyProfileSettings([{ + action: 'splice', + path: 'scanning.inputs', + start: index, + deleteCount: 1, + items: [] + }]); + return true; + } + + async setProperty(index, property, value, event) { + const path = `scanning.inputs[${index}].${property}`; + await this._settingsController.setProfileSetting(path, value); + if (event) { + this._triggerScanInputsChanged(); + } + } + + instantiateTemplate(name) { + return this._settingsController.instantiateTemplate(name); + } + + async refresh() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onScanInputsChanged({source}) { + if (source === this) { return; } + this.refresh(); + } + + _onOptionsChanged({options}) { + const {inputs} = options.scanning; + + for (let i = this._entries.length - 1; i >= 0; --i) { + this._entries[i].cleanup(); + } + this._entries.length = 0; + + for (let i = 0, ii = inputs.length; i < ii; ++i) { + this._addOption(i, inputs[i]); + } + + this._updateCounts(); + } + + _onAddButtonClick(e) { + e.preventDefault(); + + const index = this._entries.length; + const scanningInput = ScanInputsController.createDefaultMouseInput('', ''); + this._addOption(index, scanningInput); + this._updateCounts(); + this._modifyProfileSettings([{ + action: 'splice', + path: 'scanning.inputs', + start: index, + deleteCount: 0, + items: [scanningInput] + }]); + } + + _addOption(index, scanningInput) { + 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) { + node.textContent = stringValue; + } + } + + async _modifyProfileSettings(targets) { + await this._settingsController.modifyProfileSettings(targets); + this._triggerScanInputsChanged(); + } + + _triggerScanInputsChanged() { + this._settingsController.trigger('scanInputsChanged', {source: this}); + } + + static createDefaultMouseInput(include, exclude) { + return { + include, + exclude, + types: {mouse: true, touch: false, pen: false}, + options: { + showAdvanced: false, + searchTerms: true, + searchKanji: true, + scanOnTouchMove: true, + scanOnPenHover: true, + scanOnPenPress: true, + scanOnPenRelease: false, + preventTouchScrolling: true + } + }; + } +} + +class ScanInputField { + constructor(parent, index, os) { + this._parent = parent; + this._index = index; + this._os = os; + this._node = null; + this._includeInputField = null; + this._excludeInputField = null; + this._eventListeners = new EventListenerCollection(); + } + + get index() { + return this._index; + } + + set index(value) { + this._index = value; + this._updateDataSettingTargets(); + } + + 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'); + + node.dataset.showAdvanced = `${showAdvanced}`; + + this._node = node; + container.appendChild(node); + + const isPointerTypeSupported = this._isPointerTypeSupported.bind(this); + this._includeInputField = new KeyboardMouseInputField(includeInputNode, includeMouseButton, this._os, isPointerTypeSupported); + this._excludeInputField = new KeyboardMouseInputField(excludeInputNode, excludeMouseButton, this._os, isPointerTypeSupported); + this._includeInputField.prepare(null, this._splitModifiers(include), true, false); + this._excludeInputField.prepare(null, this._splitModifiers(exclude), true, false); + + this._eventListeners.on(this._includeInputField, 'change', this._onIncludeValueChange.bind(this)); + this._eventListeners.on(this._excludeInputField, 'change', this._onExcludeValueChange.bind(this)); + if (removeButton !== null) { + this._eventListeners.addEventListener(removeButton, 'click', this._onRemoveClick.bind(this)); + } + if (menuButton !== null) { + this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this)); + this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this)); + } + + this._updateDataSettingTargets(); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + if (this._includeInputField !== null) { + this._includeInputField.cleanup(); + this._includeInputField = null; + } + if (this._node !== null) { + const parent = this._node.parentNode; + if (parent !== null) { parent.removeChild(this._node); } + this._node = null; + } + } + + // Private + + _onIncludeValueChange({modifiers}) { + modifiers = this._joinModifiers(modifiers); + this._parent.setProperty(this._index, 'include', modifiers, true); + } + + _onExcludeValueChange({modifiers}) { + modifiers = this._joinModifiers(modifiers); + this._parent.setProperty(this._index, 'exclude', modifiers, true); + } + + _onRemoveClick(e) { + e.preventDefault(); + this._removeSelf(); + } + + _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'); + if (showAdvanced !== null) { + showAdvanced.hidden = advancedVisible; + } + if (hideAdvanced !== null) { + hideAdvanced.hidden = !advancedVisible; + } + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'remove': + this._removeSelf(); + break; + case 'showAdvanced': + this._setAdvancedOptionsVisible(true); + break; + case 'hideAdvanced': + this._setAdvancedOptionsVisible(false); + break; + case 'clearInputs': + this._includeInputField.clearInputs(); + this._excludeInputField.clearInputs(); + break; + } + } + + _isPointerTypeSupported(pointerType) { + if (this._node === null) { return false; } + const node = this._node.querySelector(`input.scan-input-settings-checkbox[data-property="types.${pointerType}"]`); + return node !== null && node.checked; + } + + _updateDataSettingTargets() { + const index = this._index; + for (const typeCheckbox of this._node.querySelectorAll('.scan-input-settings-checkbox')) { + const {property} = typeCheckbox.dataset; + typeCheckbox.dataset.setting = `scanning.inputs[${index}].${property}`; + } + } + + _removeSelf() { + this._parent.removeInput(this._index); + } + + _setAdvancedOptionsVisible(showAdvanced) { + showAdvanced = !!showAdvanced; + this._node.dataset.showAdvanced = `${showAdvanced}`; + this._parent.setProperty(this._index, 'options.showAdvanced', showAdvanced, false); + } + + _splitModifiers(modifiersString) { + return modifiersString.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + } + + _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 new file mode 100644 index 00000000..b011af5d --- /dev/null +++ b/ext/js/pages/settings/scan-inputs-simple-controller.js @@ -0,0 +1,246 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * HotkeyUtil + * ScanInputsController + */ + +class ScanInputsSimpleController { + constructor(settingsController) { + this._settingsController = settingsController; + this._middleMouseButtonScan = null; + this._mainScanModifierKeyInput = null; + this._mainScanModifierKeyInputHasOther = false; + this._hotkeyUtil = new HotkeyUtil(); + } + + async prepare() { + this._middleMouseButtonScan = document.querySelector('#middle-mouse-button-scan'); + this._mainScanModifierKeyInput = document.querySelector('#main-scan-modifier-key'); + + const {platform: {os}} = await yomichan.api.getEnvironmentInfo(); + this._hotkeyUtil.os = os; + + this._mainScanModifierKeyInputHasOther = false; + this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther); + + const options = await this._settingsController.getOptions(); + + 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}); + } + + async refresh() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + // Private + + _onScanInputsChanged({source}) { + if (source === this) { return; } + this.refresh(); + } + + _onOptionsChanged({options}) { + const {scanning: {inputs}} = options; + const middleMouseSupportedIndex = this._getIndexOfMiddleMouseButtonScanInput(inputs); + const mainScanInputIndex = this._getIndexOfMainScanInput(inputs); + const hasMainScanInput = (mainScanInputIndex >= 0); + + let middleMouseSupported = false; + if (middleMouseSupportedIndex >= 0) { + const includeValues = this._splitValue(inputs[middleMouseSupportedIndex].include); + if (includeValues.includes('mouse2')) { + middleMouseSupported = true; + } + } + + let mainScanInput = 'none'; + if (hasMainScanInput) { + const includeValues = this._splitValue(inputs[mainScanInputIndex].include); + if (includeValues.length > 0) { + mainScanInput = includeValues[0]; + } + } else { + mainScanInput = 'other'; + } + + this._setHasMainScanInput(hasMainScanInput); + + this._middleMouseButtonScan.checked = middleMouseSupported; + this._mainScanModifierKeyInput.value = mainScanInput; + } + + onMiddleMouseButtonScanChange(e) { + const middleMouseSupported = e.currentTarget.checked; + this._setMiddleMouseSuppported(middleMouseSupported); + } + + _onMainScanModifierKeyInputChange(e) { + const mainScanKey = e.currentTarget.value; + if (mainScanKey === 'other') { return; } + const mainScanInputs = (mainScanKey === 'none' ? [] : [mainScanKey]); + this._setMainScanInputs(mainScanInputs); + } + + _populateSelect(select, hasOther) { + const modifierKeys = [ + {value: 'none', name: 'No key'} + ]; + for (const value of ['alt', 'ctrl', 'shift', 'meta']) { + const name = this._hotkeyUtil.getModifierDisplayValue(value); + modifierKeys.push({value, name}); + } + + if (hasOther) { + modifierKeys.push({value: 'other', name: 'Other'}); + } + + const fragment = document.createDocumentFragment(); + for (const {value, name} of modifierKeys) { + const option = document.createElement('option'); + option.value = value; + option.textContent = name; + fragment.appendChild(option); + } + select.textContent = ''; + select.appendChild(fragment); + } + + _splitValue(value) { + return value.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0); + } + + async _setMiddleMouseSuppported(value) { + // Find target index + const options = await this._settingsController.getOptions(); + const {scanning: {inputs}} = options; + const index = this._getIndexOfMiddleMouseButtonScanInput(inputs); + + if (value) { + // Add new + if (index >= 0) { return; } + let insertionPosition = this._getIndexOfMainScanInput(inputs); + insertionPosition = (insertionPosition >= 0 ? insertionPosition + 1 : inputs.length); + const input = ScanInputsController.createDefaultMouseInput('mouse2', ''); + await this._modifyProfileSettings([{ + action: 'splice', + path: 'scanning.inputs', + start: insertionPosition, + deleteCount: 0, + items: [input] + }]); + } else { + // Modify existing + if (index < 0) { return; } + await this._modifyProfileSettings([{ + action: 'splice', + path: 'scanning.inputs', + start: index, + deleteCount: 1, + items: [] + }]); + } + } + + async _setMainScanInputs(value) { + value = value.join(', '); + + // Find target index + const options = await this._settingsController.getOptions(); + const {scanning: {inputs}} = options; + const index = this._getIndexOfMainScanInput(inputs); + + this._setHasMainScanInput(true); + + if (index < 0) { + // Add new + const input = ScanInputsController.createDefaultMouseInput(value, 'mouse0'); + await this._modifyProfileSettings([{ + action: 'splice', + path: 'scanning.inputs', + start: inputs.length, + deleteCount: 0, + items: [input] + }]); + } else { + // Modify existing + await this._modifyProfileSettings([{ + action: 'set', + path: `scanning.inputs[${index}].include`, + value + }]); + } + } + + async _modifyProfileSettings(targets) { + await this._settingsController.modifyProfileSettings(targets); + this._settingsController.trigger('scanInputsChanged', {source: this}); + } + + _getIndexOfMainScanInput(inputs) { + for (let i = 0, ii = inputs.length; i < ii; ++i) { + const {include, exclude, types: {mouse}} = inputs[i]; + if (!mouse) { continue; } + const includeValues = this._splitValue(include); + const excludeValues = this._splitValue(exclude); + if ( + ( + includeValues.length === 0 || + (includeValues.length === 1 && !this._isMouseInput(includeValues[0])) + ) && + excludeValues.length === 1 && + excludeValues[0] === 'mouse0' + ) { + return i; + } + } + return -1; + } + + _getIndexOfMiddleMouseButtonScanInput(inputs) { + for (let i = 0, ii = inputs.length; i < ii; ++i) { + const {include, exclude, types: {mouse}} = inputs[i]; + if (!mouse) { continue; } + const includeValues = this._splitValue(include); + const excludeValues = this._splitValue(exclude); + if ( + (includeValues.length === 1 && includeValues[0] === 'mouse2') && + excludeValues.length === 0 + ) { + return i; + } + } + return -1; + } + + _isMouseInput(input) { + return /^mouse\d+$/.test(input); + } + + _setHasMainScanInput(hasMainScanInput) { + if (this._mainScanModifierKeyInputHasOther !== hasMainScanInput) { return; } + this._mainScanModifierKeyInputHasOther = !hasMainScanInput; + this._populateSelect(this._mainScanModifierKeyInput, this._mainScanModifierKeyInputHasOther); + } +}
\ No newline at end of file diff --git a/ext/js/pages/settings/secondary-search-dictionary-controller.js b/ext/js/pages/settings/secondary-search-dictionary-controller.js new file mode 100644 index 00000000..2fb3de67 --- /dev/null +++ b/ext/js/pages/settings/secondary-search-dictionary-controller.js @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * ObjectPropertyAccessor + */ + +class SecondarySearchDictionaryController { + constructor(settingsController) { + this._settingsController = settingsController; + this._getDictionaryInfoToken = null; + this._container = null; + this._eventListeners = new EventListenerCollection(); + } + + async prepare() { + this._container = document.querySelector('#secondary-search-dictionary-list'); + + yomichan.on('databaseUpdated', this._onDatabaseUpdated.bind(this)); + + await this._onDatabaseUpdated(); + } + + // Private + + async _onDatabaseUpdated() { + this._eventListeners.removeAllEventListeners(); + + const token = {}; + this._getDictionaryInfoToken = token; + const dictionaries = await this._settingsController.getDictionaryInfo(); + if (this._getDictionaryInfoToken !== token) { return; } + this._getDictionaryInfoToken = null; + + const fragment = document.createDocumentFragment(); + for (const {title, revision} of dictionaries) { + const node = this._settingsController.instantiateTemplate('secondary-search-dictionary'); + fragment.appendChild(node); + + const nameNode = node.querySelector('.dictionary-title'); + nameNode.textContent = title; + + const versionNode = node.querySelector('.dictionary-version'); + versionNode.textContent = `rev.${revision}`; + + const toggle = node.querySelector('.dictionary-allow-secondary-searches'); + toggle.dataset.setting = ObjectPropertyAccessor.getPathString(['dictionaries', title, 'allowSecondarySearches']); + this._eventListeners.addEventListener(toggle, 'settingChanged', this._onEnabledChanged.bind(this, node), false); + } + + this._container.textContent = ''; + this._container.appendChild(fragment); + } + + _onEnabledChanged(node, e) { + const {detail: {value}} = e; + node.dataset.enabled = `${value}`; + } +}
\ No newline at end of file diff --git a/ext/js/pages/settings/sentence-termination-characters-controller.js b/ext/js/pages/settings/sentence-termination-characters-controller.js new file mode 100644 index 00000000..d62771ec --- /dev/null +++ b/ext/js/pages/settings/sentence-termination-characters-controller.js @@ -0,0 +1,243 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class SentenceTerminationCharactersController { + constructor(settingsController) { + this._settingsController = settingsController; + this._entries = []; + this._addButton = null; + this._resetButton = null; + this._listTable = null; + this._listContainer = null; + this._emptyIndicator = null; + } + + 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.addEventListener('click', this._onAddClick.bind(this)); + this._resetButton.addEventListener('click', this._onResetClick.bind(this)); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + await this._updateOptions(); + } + + async addEntry(terminationCharacterEntry) { + const options = await this._settingsController.getOptions(); + const {sentenceParsing: {terminationCharacters}} = options; + + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'sentenceParsing.terminationCharacters', + start: terminationCharacters.length, + deleteCount: 0, + items: [terminationCharacterEntry] + }]); + + await this._updateOptions(); + } + + async deleteEntry(index) { + const options = await this._settingsController.getOptions(); + const {sentenceParsing: {terminationCharacters}} = options; + + if (index < 0 || index >= terminationCharacters.length) { return false; } + + await this._settingsController.modifyProfileSettings([{ + action: 'splice', + path: 'sentenceParsing.terminationCharacters', + start: index, + deleteCount: 1, + items: [] + }]); + + await this._updateOptions(); + return true; + } + + async modifyProfileSettings(targets) { + return await this._settingsController.modifyProfileSettings(targets); + } + + // Private + + _onOptionsChanged({options}) { + for (const entry of this._entries) { + entry.cleanup(); + } + + this._entries = []; + const {sentenceParsing: {terminationCharacters}} = options; + + 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 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); + } + + _onAddClick(e) { + e.preventDefault(); + this._addNewEntry(); + } + + _onResetClick(e) { + e.preventDefault(); + this._reset(); + } + + async _addNewEntry() { + const newEntry = { + enabled: true, + character1: '"', + character2: '"', + includeCharacterAtStart: false, + includeCharacterAtEnd: false + }; + return await this.addEntry(newEntry); + } + + async _updateOptions() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + async _reset() { + const defaultOptions = await this._settingsController.getDefaultOptions(); + const value = defaultOptions.profiles[0].options.sentenceParsing.terminationCharacters; + await this._settingsController.setProfileSetting('sentenceParsing.terminationCharacters', value); + await this._updateOptions(); + } +} + +class SentenceTerminationCharacterEntry { + constructor(parent, data, index, node) { + this._parent = parent; + this._data = data; + this._index = index; + this._node = node; + this._eventListeners = new EventListenerCollection(); + this._character1Input = null; + this._character2Input = null; + 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'); + + this._character1Input = character1Input; + this._character2Input = character2Input; + + const type = (character2 === null ? 'terminator' : 'quote'); + node.dataset.type = type; + + enabledToggle.checked = enabled; + typeSelect.value = type; + character1Input.value = character1; + character2Input.value = (character2 !== null ? character2 : ''); + includeAtStartCheckbox.checked = includeCharacterAtStart; + includeAtEndheckbox.checked = includeCharacterAtEnd; + + enabledToggle.dataset.setting = `${this._basePath}.enabled`; + includeAtStartCheckbox.dataset.setting = `${this._basePath}.includeCharacterAtStart`; + includeAtEndheckbox.dataset.setting = `${this._basePath}.includeCharacterAtEnd`; + + this._eventListeners.addEventListener(typeSelect, 'change', this._onTypeSelectChange.bind(this), false); + this._eventListeners.addEventListener(character1Input, 'change', this._onCharacterChange.bind(this, 1), false); + this._eventListeners.addEventListener(character2Input, 'change', this._onCharacterChange.bind(this, 2), false); + this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + if (this._node.parentNode !== null) { + this._node.parentNode.removeChild(this._node); + } + } + + // Private + + _onTypeSelectChange(e) { + this._setHasCharacter2(e.currentTarget.value === 'quote'); + } + + _onCharacterChange(characterNumber, e) { + const node = e.currentTarget; + if (characterNumber === 2 && this._data.character2 === null) { + node.value = ''; + } + + const value = node.value.substring(0, 1); + this._setCharacterValue(node, characterNumber, value); + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'delete': + this._delete(); + break; + } + } + + async _delete() { + this._parent.deleteEntry(this._index); + } + + async _setHasCharacter2(has) { + const okay = await this._setCharacterValue(this._character2Input, 2, has ? this._data.character1 : null); + if (okay) { + const type = (!has ? 'terminator' : 'quote'); + this._node.dataset.type = type; + } + } + + async _setCharacterValue(inputNode, characterNumber, value) { + const pathEnd = `character${characterNumber}`; + const r = await this._parent.settingsController.setProfileSetting(`${this._basePath}.${pathEnd}`, value); + const okay = !r[0].error; + if (okay) { + this._data[pathEnd] = value; + } else { + value = this._data[pathEnd]; + } + 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 new file mode 100644 index 00000000..4a86470d --- /dev/null +++ b/ext/js/pages/settings/settings-controller.js @@ -0,0 +1,209 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * HtmlTemplateCollection + * OptionsUtil + * PermissionsUtil + */ + +class SettingsController extends EventDispatcher { + constructor(profileIndex=0) { + super(); + this._profileIndex = profileIndex; + this._source = generateId(16); + this._pageExitPreventions = new Set(); + this._pageExitPreventionEventListeners = new EventListenerCollection(); + this._templates = new HtmlTemplateCollection(document); + this._permissionsUtil = new PermissionsUtil(); + } + + get source() { + return this._source; + } + + get profileIndex() { + return this._profileIndex; + } + + set profileIndex(value) { + if (this._profileIndex === value) { return; } + this._setProfileIndex(value); + } + + get permissionsUtil() { + return this._permissionsUtil; + } + + prepare() { + yomichan.on('optionsUpdated', this._onOptionsUpdated.bind(this)); + chrome.permissions.onAdded.addListener(this._onPermissionsChanged.bind(this)); + chrome.permissions.onRemoved.addListener(this._onPermissionsChanged.bind(this)); + } + + async refresh() { + await this._onOptionsUpdatedInternal(); + } + + async getOptions() { + const optionsContext = this.getOptionsContext(); + return await yomichan.api.optionsGet(optionsContext); + } + + async getOptionsFull() { + return await yomichan.api.optionsGetFull(); + } + + async setAllSettings(value) { + const profileIndex = value.profileCurrent; + await yomichan.api.setAllSettings(value, this._source); + this._setProfileIndex(profileIndex); + } + + async getSettings(targets) { + return await this._getSettings(targets, {}); + } + + async getGlobalSettings(targets) { + return await this._getSettings(targets, {scope: 'global'}); + } + + async getProfileSettings(targets) { + return await this._getSettings(targets, {scope: 'profile'}); + } + + async modifySettings(targets) { + return await this._modifySettings(targets, {}); + } + + async modifyGlobalSettings(targets) { + return await this._modifySettings(targets, {scope: 'global'}); + } + + async modifyProfileSettings(targets) { + return await this._modifySettings(targets, {scope: 'profile'}); + } + + async setGlobalSetting(path, value) { + return await this.modifyGlobalSettings([{action: 'set', path, value}]); + } + + async setProfileSetting(path, value) { + return await this.modifyProfileSettings([{action: 'set', path, value}]); + } + + async getDictionaryInfo() { + return await yomichan.api.getDictionaryInfo(); + } + + getOptionsContext() { + return {index: this._profileIndex}; + } + + preventPageExit() { + const obj = {end: null}; + obj.end = this._endPreventPageExit.bind(this, obj); + if (this._pageExitPreventionEventListeners.size === 0) { + this._pageExitPreventionEventListeners.addEventListener(window, 'beforeunload', this._onBeforeUnload.bind(this), false); + } + this._pageExitPreventions.add(obj); + return obj; + } + + instantiateTemplate(name) { + return this._templates.instantiate(name); + } + + instantiateTemplateFragment(name) { + return this._templates.instantiateFragment(name); + } + + async getDefaultOptions() { + const optionsUtil = new OptionsUtil(); + await optionsUtil.prepare(); + const optionsFull = optionsUtil.getDefault(); + return optionsFull; + } + + // Private + + _setProfileIndex(value) { + this._profileIndex = value; + this.trigger('optionsContextChanged'); + this._onOptionsUpdatedInternal(); + } + + _onOptionsUpdated({source}) { + if (source === this._source) { return; } + this._onOptionsUpdatedInternal(); + } + + async _onOptionsUpdatedInternal() { + const optionsContext = this.getOptionsContext(); + const options = await this.getOptions(); + this.trigger('optionsChanged', {options, optionsContext}); + } + + _setupTargets(targets, extraFields) { + return targets.map((target) => { + target = Object.assign({}, extraFields, target); + if (target.scope === 'profile') { + target.optionsContext = this.getOptionsContext(); + } + return target; + }); + } + + async _getSettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await yomichan.api.getSettings(targets); + } + + async _modifySettings(targets, extraFields) { + targets = this._setupTargets(targets, extraFields); + return await yomichan.api.modifySettings(targets, this._source); + } + + _onBeforeUnload(e) { + if (this._pageExitPreventions.size === 0) { + return; + } + + e.preventDefault(); + e.returnValue = ''; + return ''; + } + + _endPreventPageExit(obj) { + this._pageExitPreventions.delete(obj); + if (this._pageExitPreventions.size === 0) { + this._pageExitPreventionEventListeners.removeAllEventListeners(); + } + } + + _onPermissionsChanged() { + this._triggerPermissionsChanged(); + } + + async _triggerPermissionsChanged() { + const event = 'permissionsChanged'; + if (!this.hasListeners(event)) { return; } + + const permissions = await this._permissionsUtil.getAllPermissions(); + this.trigger(event, {permissions}); + } +} diff --git a/ext/js/pages/settings/settings-display-controller.js b/ext/js/pages/settings/settings-display-controller.js new file mode 100644 index 00000000..9d3e5459 --- /dev/null +++ b/ext/js/pages/settings/settings-display-controller.js @@ -0,0 +1,400 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * PopupMenu + * SelectorObserver + */ + +class SettingsDisplayController { + constructor(settingsController, modalController) { + this._settingsController = settingsController; + this._modalController = modalController; + this._contentNode = null; + this._menuContainer = null; + this._onMoreToggleClickBind = null; + this._onMenuButtonClickBind = null; + } + + prepare() { + this._contentNode = document.querySelector('.content'); + this._menuContainer = document.querySelector('#popup-menus'); + + const onFabButtonClick = this._onFabButtonClick.bind(this); + for (const fabButton of document.querySelectorAll('.fab-button')) { + fabButton.addEventListener('click', onFabButtonClick, false); + } + + const onModalAction = this._onModalAction.bind(this); + for (const node of 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]')) { + node.addEventListener('click', onSelectOnClickElementClick, false); + } + + const onInputTabActionKeyDown = this._onInputTabActionKeyDown.bind(this); + for (const node of document.querySelectorAll('[data-tab-action]')) { + node.addEventListener('keydown', onInputTabActionKeyDown, false); + } + + const onSpecialUrlLinkClick = this._onSpecialUrlLinkClick.bind(this); + const onSpecialUrlLinkMouseDown = this._onSpecialUrlLinkMouseDown.bind(this); + for (const node of document.querySelectorAll('[data-special-url]')) { + node.addEventListener('click', onSpecialUrlLinkClick, false); + node.addEventListener('auxclick', onSpecialUrlLinkClick, false); + node.addEventListener('mousedown', onSpecialUrlLinkMouseDown, false); + } + + for (const node of 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), + onRemoved: this._onMoreCleanup.bind(this) + }); + moreSelectorObserver.observe(document.documentElement, false); + + this._onMenuButtonClickBind = this._onMenuButtonClick.bind(this); + const menuSelectorObserver = new SelectorObserver({ + selector: '[data-menu]', + onAdded: this._onMenuSetup.bind(this), + onRemoved: this._onMenuCleanup.bind(this) + }); + menuSelectorObserver.observe(document.documentElement, false); + + window.addEventListener('keydown', this._onKeyDown.bind(this), false); + window.addEventListener('popstate', this._onPopState.bind(this), false); + this._updateScrollTarget(); + } + + // Private + + _onMoreSetup(element) { + element.addEventListener('click', this._onMoreToggleClickBind, false); + return null; + } + + _onMoreCleanup(element) { + element.removeEventListener('click', this._onMoreToggleClickBind, false); + } + + _onMenuSetup(element) { + element.addEventListener('click', this._onMenuButtonClickBind, false); + return null; + } + + _onMenuCleanup(element) { + element.removeEventListener('click', this._onMenuButtonClickBind, false); + } + + _onMenuButtonClick(e) { + const element = e.currentTarget; + const {menu} = element.dataset; + this._showMenu(element, menu); + } + + _onFabButtonClick(e) { + const action = e.currentTarget.dataset.action; + switch (action) { + case 'toggle-sidebar': + document.body.classList.toggle('sidebar-visible'); + break; + case 'toggle-preview-sidebar': + document.body.classList.toggle('preview-sidebar-visible'); + break; + } + } + + _onMoreToggleClick(e) { + const container = this._getMoreContainer(e.currentTarget); + if (container === null) { return; } + + const more = container.querySelector('.more'); + if (more === null) { return; } + + const moreVisible = more.hidden; + more.hidden = !moreVisible; + for (const moreToggle of container.querySelectorAll('.more-toggle')) { + const container2 = this._getMoreContainer(moreToggle); + if (container2 === null) { continue; } + + const more2 = container2.querySelector('.more'); + if (more2 === null || more2 !== more) { continue; } + + moreToggle.dataset.expanded = `${moreVisible}`; + } + + e.preventDefault(); + return false; + } + + _onPopState() { + this._updateScrollTarget(); + } + + _onKeyDown(e) { + switch (e.code) { + case 'Escape': + if (!this._isElementAnInput(document.activeElement)) { + this._closeTopMenuOrModal(); + e.preventDefault(); + } + break; + } + } + + _onModalAction(e) { + const node = e.currentTarget; + const {modalAction} = node.dataset; + if (typeof modalAction !== 'string') { return; } + + let [action, target] = modalAction.split(','); + if (typeof target === 'undefined') { + const currentModal = node.closest('.modal'); + if (currentModal === null) { return; } + target = currentModal; + } + + const modal = this._modalController.getModal(target); + if (modal === null) { return; } + + switch (action) { + case 'show': + modal.setVisible(true); + break; + case 'hide': + modal.setVisible(false); + break; + case 'toggle': + modal.setVisible(!modal.isVisible()); + break; + } + + e.preventDefault(); + } + + _onSelectOnClickElementClick(e) { + if (e.button !== 0) { return; } + + const node = e.currentTarget; + const range = document.createRange(); + range.selectNode(node); + + const selection = window.getSelection(); + selection.removeAllRanges(); + selection.addRange(range); + + e.preventDefault(); + e.stopPropagation(); + return false; + } + + _onInputTabActionKeyDown(e) { + if (e.key !== 'Tab' || e.ctrlKey) { return; } + + const node = e.currentTarget; + const {tabAction} = node.dataset; + if (typeof tabAction !== 'string') { return; } + + const args = tabAction.split(','); + switch (args[0]) { + case 'ignore': + e.preventDefault(); + break; + case 'indent': + e.preventDefault(); + this._indentInput(e, node, args); + break; + } + } + + _onSpecialUrlLinkClick(e) { + switch (e.button) { + case 0: + case 1: + e.preventDefault(); + this._createTab(e.currentTarget.dataset.specialUrl, true); + break; + } + } + + _onSpecialUrlLinkMouseDown(e) { + switch (e.button) { + case 0: + case 1: + e.preventDefault(); + break; + } + } + + async _createTab(url, useOpener) { + let openerTabId; + if (useOpener) { + try { + const tab = await new Promise((resolve, reject) => { + chrome.tabs.getCurrent((result) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(result); + } + }); + }); + openerTabId = tab.id; + } catch (e) { + // NOP + } + } + + return await new Promise((resolve, reject) => { + chrome.tabs.create({url, openerTabId}, (tab2) => { + const e = chrome.runtime.lastError; + if (e) { + reject(new Error(e.message)); + } else { + resolve(tab2); + } + }); + }); + } + + _updateScrollTarget() { + const hash = window.location.hash; + if (!hash.startsWith('#!')) { return; } + + const content = this._contentNode; + const target = document.getElementById(hash.substring(2)); + if (content === null || target === null) { return; } + + const rect1 = content.getBoundingClientRect(); + const rect2 = target.getBoundingClientRect(); + content.scrollTop += rect2.top - rect1.top; + } + + _getMoreContainer(link) { + const v = link.dataset.parentDistance; + const distance = v ? parseInt(v, 10) : 1; + if (Number.isNaN(distance)) { return null; } + + for (let i = 0; i < distance; ++i) { + link = link.parentNode; + if (link === null) { break; } + } + return link; + } + + _closeTopMenuOrModal() { + for (const popupMenu of PopupMenu.openMenus) { + popupMenu.close(); + return; + } + + const modal = this._modalController.getTopVisibleModal(); + if (modal !== null) { + modal.setVisible(false); + } + } + + _showMenu(element, menuName) { + const menu = this._settingsController.instantiateTemplate(menuName); + if (menu === null) { return; } + + this._menuContainer.appendChild(menu); + + const popupMenu = new PopupMenu(element, menu); + popupMenu.prepare(); + } + + _indentInput(e, node, args) { + let indent = '\t'; + if (args.length > 1) { + const count = parseInt(args[1], 10); + indent = (Number.isFinite(count) && count >= 0 ? ' '.repeat(count) : args[1]); + } + + const {selectionStart: start, selectionEnd: end, value} = node; + const lineStart = value.substring(0, start).lastIndexOf('\n') + 1; + const lineWhitespace = /^[ \t]*/.exec(value.substring(lineStart))[0]; + + if (e.shiftKey) { + const whitespaceLength = Math.max(0, Math.floor((lineWhitespace.length - 1) / 4) * 4); + const selectionStartNew = lineStart + whitespaceLength; + const selectionEndNew = lineStart + lineWhitespace.length; + const removeCount = selectionEndNew - selectionStartNew; + if (removeCount > 0) { + node.selectionStart = selectionStartNew; + node.selectionEnd = selectionEndNew; + document.execCommand('delete', false); + node.selectionStart = Math.max(lineStart, start - removeCount); + node.selectionEnd = Math.max(lineStart, end - removeCount); + } + } else { + if (indent.length > 0) { + const indentLength = (Math.ceil((start - lineStart + 1) / indent.length) * indent.length - (start - lineStart)); + document.execCommand('insertText', false, indent.substring(0, indentLength)); + } + } + } + + _isElementAnInput(element) { + const type = element !== null ? element.nodeName.toUpperCase() : null; + switch (type) { + case 'INPUT': + case 'TEXTAREA': + case 'SELECT': + return true; + default: + return false; + } + } + + _setupDeferLoadIframe(element) { + const parent = this._getMoreContainer(element); + if (parent === null) { return; } + + let mutationObserver = null; + const callback = () => { + if (!this._isElementVisible(element)) { return false; } + + const src = element.dataset.src; + delete element.dataset.src; + element.src = src; + + if (mutationObserver === null) { return true; } + + mutationObserver.disconnect(); + mutationObserver = null; + return true; + }; + + if (callback()) { return; } + + mutationObserver = new MutationObserver(callback); + mutationObserver.observe(parent, {attributes: true}); + } + + _isElementVisible(element) { + return (element.offsetParent !== null); + } +} diff --git a/ext/js/pages/settings/settings-main.js b/ext/js/pages/settings/settings-main.js new file mode 100644 index 00000000..273142cd --- /dev/null +++ b/ext/js/pages/settings/settings-main.js @@ -0,0 +1,154 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * AnkiController + * AnkiTemplatesController + * AudioController + * BackupController + * DictionaryController + * DictionaryImportController + * DocumentFocusController + * ExtensionKeyboardShortcutController + * GenericSettingController + * KeyboardShortcutController + * MecabController + * ModalController + * NestedPopupsController + * PermissionsToggleController + * PopupPreviewController + * PopupWindowController + * ProfileController + * ScanInputsController + * ScanInputsSimpleController + * SecondarySearchDictionaryController + * SentenceTerminationCharactersController + * SettingsController + * SettingsDisplayController + * StatusFooter + * StorageController + * TranslationTextReplacementsController + */ + +async function setupEnvironmentInfo() { + const {manifest_version: manifestVersion} = chrome.runtime.getManifest(); + const {browser, platform} = await yomichan.api.getEnvironmentInfo(); + document.documentElement.dataset.browser = browser; + document.documentElement.dataset.os = platform.os; + document.documentElement.dataset.manifestVersion = `${manifestVersion}`; +} + +async function setupGenericSettingsController(genericSettingController) { + await genericSettingController.prepare(); + await genericSettingController.refresh(); +} + +(async () => { + try { + const documentFocusController = new DocumentFocusController(); + documentFocusController.prepare(); + + const statusFooter = new StatusFooter(document.querySelector('.status-footer-container')); + statusFooter.prepare(); + + await yomichan.prepare(); + + setupEnvironmentInfo(); + + const optionsFull = await yomichan.api.optionsGetFull(); + + const preparePromises = []; + + const modalController = new ModalController(); + modalController.prepare(); + + const settingsController = new SettingsController(optionsFull.profileCurrent); + settingsController.prepare(); + + const storageController = new StorageController(); + storageController.prepare(); + + const dictionaryController = new DictionaryController(settingsController, modalController, storageController, statusFooter); + dictionaryController.prepare(); + + const dictionaryImportController = new DictionaryImportController(settingsController, modalController, storageController, statusFooter); + dictionaryImportController.prepare(); + + const genericSettingController = new GenericSettingController(settingsController); + preparePromises.push(setupGenericSettingsController(genericSettingController)); + + const audioController = new AudioController(settingsController); + audioController.prepare(); + + const profileController = new ProfileController(settingsController, modalController); + profileController.prepare(); + + const settingsBackup = new BackupController(settingsController, modalController); + settingsBackup.prepare(); + + const ankiController = new AnkiController(settingsController); + ankiController.prepare(); + + const ankiTemplatesController = new AnkiTemplatesController(settingsController, modalController, ankiController); + ankiTemplatesController.prepare(); + + const popupPreviewController = new PopupPreviewController(settingsController); + popupPreviewController.prepare(); + + const scanInputsController = new ScanInputsController(settingsController); + scanInputsController.prepare(); + + const simpleScanningInputController = new ScanInputsSimpleController(settingsController); + simpleScanningInputController.prepare(); + + const nestedPopupsController = new NestedPopupsController(settingsController); + nestedPopupsController.prepare(); + + const permissionsToggleController = new PermissionsToggleController(settingsController); + permissionsToggleController.prepare(); + + const secondarySearchDictionaryController = new SecondarySearchDictionaryController(settingsController); + secondarySearchDictionaryController.prepare(); + + const translationTextReplacementsController = new TranslationTextReplacementsController(settingsController); + translationTextReplacementsController.prepare(); + + const sentenceTerminationCharactersController = new SentenceTerminationCharactersController(settingsController); + sentenceTerminationCharactersController.prepare(); + + const keyboardShortcutController = new KeyboardShortcutController(settingsController); + keyboardShortcutController.prepare(); + + const extensionKeyboardShortcutController = new ExtensionKeyboardShortcutController(settingsController); + extensionKeyboardShortcutController.prepare(); + + const popupWindowController = new PopupWindowController(); + popupWindowController.prepare(); + + const mecabController = new MecabController(); + mecabController.prepare(); + + await Promise.all(preparePromises); + + document.documentElement.dataset.loaded = 'true'; + + const settingsDisplayController = new SettingsDisplayController(settingsController, modalController); + settingsDisplayController.prepare(); + } catch (e) { + log.error(e); + } +})(); diff --git a/ext/js/pages/settings/status-footer.js b/ext/js/pages/settings/status-footer.js new file mode 100644 index 00000000..c03e6775 --- /dev/null +++ b/ext/js/pages/settings/status-footer.js @@ -0,0 +1,84 @@ +/* + * Copyright (C) 2020-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +/* global + * PanelElement + */ + +class StatusFooter extends PanelElement { + constructor(node) { + super({ + node, + closingAnimationDuration: 375 // Milliseconds; includes buffer + }); + this._body = 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); + } + + getTaskContainer(selector) { + return this._body.querySelector(selector); + } + + isTaskActive(selector) { + const target = this.getTaskContainer(selector); + return (target !== null && target.dataset.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')) { + if (element.dataset.active) { + activeElements.add(element); + } + } + + if (active) { + target.dataset.active = 'true'; + if (!this.isVisible()) { + this.setVisible(true); + } + target.hidden = false; + } else { + delete target.dataset.active; + if (activeElements.size <= 1) { + this.setVisible(false); + } + } + } + + // Private + + _onCloseClick(e) { + e.preventDefault(); + this.setVisible(false); + } + + _onCloseCompleted() { + for (const element of 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 new file mode 100644 index 00000000..c27c8690 --- /dev/null +++ b/ext/js/pages/settings/storage-controller.js @@ -0,0 +1,183 @@ +/* + * Copyright (C) 2019-2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class StorageController { + constructor() { + this._mostRecentStorageEstimate = null; + this._storageEstimateFailed = false; + this._isUpdating = false; + this._persistentStorageCheckbox = false; + this._storageUsageNode = null; + this._storageQuotaNode = null; + this._storageUseFiniteNodes = null; + this._storageUseInfiniteNodes = null; + this._storageUseValidNodes = null; + this._storageUseInvalidNodes = null; + } + + prepare() { + this._persistentStorageCheckbox = document.querySelector('#storage-persistent-checkbox'); + 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'); + + this._preparePersistentStorage(); + this.updateStats(); + this._persistentStorageCheckbox.addEventListener('change', this._onPersistentStorageCheckboxChange.bind(this), false); + document.querySelector('#storage-refresh').addEventListener('click', this.updateStats.bind(this), false); + } + + async updateStats() { + if (this._isUpdating) { return; } + + try { + this._isUpdating = true; + + const estimate = await this._storageEstimate(); + const valid = (estimate !== null); + + // Firefox reports usage as 0 when persistent storage is enabled. + const finite = valid && (estimate.usage > 0 || !(await this._isStoragePeristent())); + if (finite) { + for (const node of this._storageUsageNodes) { + node.textContent = this._bytesToLabeledString(estimate.usage); + } + for (const node of this._storageQuotaNodes) { + node.textContent = this._bytesToLabeledString(estimate.quota); + } + } + + this._setElementsVisible(this._storageUseFiniteNodes, valid && finite); + this._setElementsVisible(this._storageUseInfiniteNodes, valid && !finite); + this._setElementsVisible(this._storageUseValidNodes, valid); + this._setElementsVisible(this._storageUseInvalidNodes, !valid); + + return valid; + } finally { + this._isUpdating = false; + } + } + + // Private + + async _preparePersistentStorage() { + if (!(navigator.storage && navigator.storage.persist)) { + // Not supported + return; + } + + const info = document.querySelector('#storage-persistent-info'); + if (info !== null) { info.hidden = false; } + + const isStoragePeristent = await this._isStoragePeristent(); + this._updateCheckbox(isStoragePeristent); + + const button = document.querySelector('#storage-persistent-button'); + if (button !== null) { + button.hidden = false; + button.addEventListener('click', this._onPersistStorageButtonClick.bind(this), false); + } + } + + _onPersistentStorageCheckboxChange(e) { + const node = e.currentTarget; + if (!node.checked) { + node.checked = true; + return; + } + this._attemptPersistStorage(); + } + + _onPersistStorageButtonClick() { + const {checked} = this._persistentStorageCheckbox; + if (checked) { return; } + this._persistentStorageCheckbox.checked = !checked; + this._persistentStorageCheckbox.dispatchEvent(new Event('change')); + } + + async _attemptPersistStorage() { + if (await this._isStoragePeristent()) { return; } + + let isStoragePeristent = false; + try { + isStoragePeristent = await navigator.storage.persist(); + } catch (e) { + // NOP + } + + this._updateCheckbox(isStoragePeristent); + + if (isStoragePeristent) { + this.updateStats(); + } else { + const node = document.querySelector('#storage-persistent-fail-warning'); + if (node !== null) { node.hidden = false; } + } + } + + async _storageEstimate() { + if (this._storageEstimateFailed && this._mostRecentStorageEstimate === null) { + return null; + } + try { + const value = await navigator.storage.estimate(); + this._mostRecentStorageEstimate = value; + return value; + } catch (e) { + this._storageEstimateFailed = true; + } + return null; + } + + _bytesToLabeledString(size) { + const base = 1000; + const labels = [' bytes', 'KB', 'MB', 'GB', 'TB']; + const maxLabelIndex = labels.length - 1; + let labelIndex = 0; + while (size >= base && labelIndex < maxLabelIndex) { + size /= base; + ++labelIndex; + } + const label = labelIndex === 0 ? `${size}` : size.toFixed(1); + return `${label}${labels[labelIndex]}`; + } + + async _isStoragePeristent() { + try { + return await navigator.storage.persisted(); + } catch (e) { + // NOP + } + return false; + } + + _updateCheckbox(isStoragePeristent) { + const checkbox = this._persistentStorageCheckbox; + checkbox.checked = isStoragePeristent; + checkbox.readOnly = isStoragePeristent; + } + + _setElementsVisible(elements, visible) { + 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 new file mode 100644 index 00000000..8d13f7e9 --- /dev/null +++ b/ext/js/pages/settings/translation-text-replacements-controller.js @@ -0,0 +1,242 @@ +/* + * Copyright (C) 2021 Yomichan Authors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see <https://www.gnu.org/licenses/>. + */ + +class TranslationTextReplacementsController { + constructor(settingsController) { + this._settingsController = settingsController; + this._entryContainer = null; + this._entries = []; + } + + async prepare() { + this._entryContainer = document.querySelector('#translation-text-replacement-list'); + const addButton = document.querySelector('#translation-text-replacement-add'); + + addButton.addEventListener('click', this._onAdd.bind(this), false); + this._settingsController.on('optionsChanged', this._onOptionsChanged.bind(this)); + + await this._updateOptions(); + } + + + async addGroup() { + const options = await this._settingsController.getOptions(); + const {groups} = options.translation.textReplacements; + const newEntry = this._createNewEntry(); + const target = ( + (groups.length === 0) ? + { + action: 'splice', + path: 'translation.textReplacements.groups', + start: 0, + deleteCount: 0, + items: [[newEntry]] + } : + { + action: 'splice', + path: 'translation.textReplacements.groups[0]', + start: groups[0].length, + deleteCount: 0, + items: [newEntry] + } + ); + + await this._settingsController.modifyProfileSettings([target]); + await this._updateOptions(); + } + + async deleteGroup(index) { + const options = await this._settingsController.getOptions(); + const {groups} = options.translation.textReplacements; + if (groups.length === 0) { return false; } + + const group0 = groups[0]; + if (index < 0 || index >= group0.length) { return false; } + + const target = ( + (group0.length > 1) ? + { + action: 'splice', + path: 'translation.textReplacements.groups[0]', + start: index, + deleteCount: 1, + items: [] + } : + { + action: 'splice', + path: 'translation.textReplacements.groups', + start: 0, + deleteCount: group0.length, + items: [] + } + ); + + await this._settingsController.modifyProfileSettings([target]); + await this._updateOptions(); + return true; + } + + // Private + + _onOptionsChanged({options}) { + for (const entry of this._entries) { + entry.cleanup(); + } + this._entries = []; + + const {groups} = options.translation.textReplacements; + 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); + this._entries.push(entry); + entry.prepare(); + } + } + } + + _onAdd() { + this.addGroup(); + } + + async _updateOptions() { + const options = await this._settingsController.getOptions(); + this._onOptionsChanged({options}); + } + + _createNewEntry() { + return {pattern: '', ignoreCase: false, replacement: ''}; + } +} + +class TranslationTextReplacementsEntry { + constructor(parent, node, index) { + this._parent = parent; + this._node = node; + this._index = index; + this._eventListeners = new EventListenerCollection(); + this._patternInput = null; + this._replacementInput = null; + this._ignoreCaseToggle = null; + this._testInput = null; + 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'); + + this._patternInput = patternInput; + this._replacementInput = replacementInput; + this._ignoreCaseToggle = ignoreCaseToggle; + this._testInput = testInput; + this._testOutput = testOutput; + + const pathBase = `translation.textReplacements.groups[0][${this._index}]`; + patternInput.dataset.setting = `${pathBase}.pattern`; + replacementInput.dataset.setting = `${pathBase}.replacement`; + ignoreCaseToggle.dataset.setting = `${pathBase}.ignoreCase`; + + this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); + this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + this._eventListeners.addEventListener(patternInput, 'settingChanged', this._onPatternChanged.bind(this), false); + this._eventListeners.addEventListener(ignoreCaseToggle, 'settingChanged', this._updateTestInput.bind(this), false); + this._eventListeners.addEventListener(replacementInput, 'settingChanged', this._updateTestInput.bind(this), false); + this._eventListeners.addEventListener(testInput, 'input', this._updateTestInput.bind(this), false); + } + + cleanup() { + this._eventListeners.removeAllEventListeners(); + if (this._node.parentNode !== null) { + this._node.parentNode.removeChild(this._node); + } + } + + // Private + + _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; + } + + _onMenuClose(e) { + switch (e.detail.action) { + case 'remove': + this._parent.deleteGroup(this._index); + break; + case 'showTest': + this._setTestVisible(true); + break; + case 'hideTest': + this._setTestVisible(false); + break; + } + } + + _onPatternChanged({detail: {value}}) { + this._validatePattern(value); + this._updateTestInput(); + } + + _validatePattern(value) { + let okay = false; + try { + new RegExp(value, 'g'); + okay = true; + } catch (e) { + // NOP + } + + this._patternInput.dataset.invalid = `${!okay}`; + } + + _isTestVisible() { + return this._node.dataset.testVisible === 'true'; + } + + _setTestVisible(visible) { + this._node.dataset.testVisible = `${visible}`; + this._updateTestInput(); + } + + _updateTestInput() { + if (!this._isTestVisible()) { return; } + + const ignoreCase = this._ignoreCaseToggle.checked; + const pattern = this._patternInput.value; + let regex; + try { + regex = new RegExp(pattern, ignoreCase ? 'gi' : 'g'); + } catch (e) { + return; + } + + const replacement = this._replacementInput.value; + const input = this._testInput.value; + const output = input.replace(regex, replacement); + this._testOutput.value = output; + } +} |