aboutsummaryrefslogtreecommitdiff
path: root/ext/js/settings
diff options
context:
space:
mode:
Diffstat (limited to 'ext/js/settings')
-rw-r--r--ext/js/settings/anki-controller.js729
-rw-r--r--ext/js/settings/anki-templates-controller.js228
-rw-r--r--ext/js/settings/audio-controller.js234
-rw-r--r--ext/js/settings/backup-controller.js419
-rw-r--r--ext/js/settings/dictionary-controller.js558
-rw-r--r--ext/js/settings/dictionary-import-controller.js346
-rw-r--r--ext/js/settings/extension-keyboard-shortcuts-controller.js292
-rw-r--r--ext/js/settings/generic-setting-controller.js232
-rw-r--r--ext/js/settings/keyboard-mouse-input-field.js243
-rw-r--r--ext/js/settings/keyboard-shortcuts-controller.js368
-rw-r--r--ext/js/settings/main.js111
-rw-r--r--ext/js/settings/mecab-controller.js67
-rw-r--r--ext/js/settings/modal-controller.js60
-rw-r--r--ext/js/settings/modal-jquery.js73
-rw-r--r--ext/js/settings/modal.js80
-rw-r--r--ext/js/settings/nested-popups-controller.js71
-rw-r--r--ext/js/settings/permissions-toggle-controller.js131
-rw-r--r--ext/js/settings/pitch-accents-preview-main.js33
-rw-r--r--ext/js/settings/popup-preview-controller.js129
-rw-r--r--ext/js/settings/popup-preview-frame-main.js44
-rw-r--r--ext/js/settings/popup-preview-frame.js233
-rw-r--r--ext/js/settings/popup-window-controller.js38
-rw-r--r--ext/js/settings/profile-conditions-ui.js712
-rw-r--r--ext/js/settings/profile-controller.js698
-rw-r--r--ext/js/settings/scan-inputs-controller.js310
-rw-r--r--ext/js/settings/scan-inputs-simple-controller.js247
-rw-r--r--ext/js/settings/secondary-search-dictionary-controller.js73
-rw-r--r--ext/js/settings/sentence-termination-characters-controller.js243
-rw-r--r--ext/js/settings/settings-controller.js210
-rw-r--r--ext/js/settings/settings-display-controller.js400
-rw-r--r--ext/js/settings/settings-main.js156
-rw-r--r--ext/js/settings/status-footer.js84
-rw-r--r--ext/js/settings/storage-controller.js183
-rw-r--r--ext/js/settings/translation-text-replacements-controller.js242
34 files changed, 8277 insertions, 0 deletions
diff --git a/ext/js/settings/anki-controller.js b/ext/js/settings/anki-controller.js
new file mode 100644
index 00000000..db3e3c14
--- /dev/null
+++ b/ext/js/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) {
+ yomichan.logError(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/settings/anki-templates-controller.js b/ext/js/settings/anki-templates-controller.js
new file mode 100644
index 00000000..31bd1e92
--- /dev/null
+++ b/ext/js/settings/anki-templates-controller.js
@@ -0,0 +1,228 @@
+/*
+ * 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
+ * api
+ */
+
+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 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 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/settings/audio-controller.js b/ext/js/settings/audio-controller.js
new file mode 100644
index 00000000..e62383a8
--- /dev/null
+++ b/ext/js/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/settings/backup-controller.js b/ext/js/settings/backup-controller.js
new file mode 100644
index 00000000..8837b927
--- /dev/null
+++ b/ext/js/settings/backup-controller.js
@@ -0,0 +1,419 @@
+/*
+ * 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
+ * api
+ */
+
+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 api.getEnvironmentInfo();
+ const fieldTemplatesDefault = await 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) {
+ yomichan.logError(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) {
+ yomichan.logError(e);
+ }
+ }
+}
diff --git a/ext/js/settings/dictionary-controller.js b/ext/js/settings/dictionary-controller.js
new file mode 100644
index 00000000..ea9f7503
--- /dev/null
+++ b/ext/js/settings/dictionary-controller.js
@@ -0,0 +1,558 @@
+/*
+ * 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
+ * api
+ */
+
+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 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) {
+ yomichan.logError(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);
+ 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/settings/dictionary-import-controller.js b/ext/js/settings/dictionary-import-controller.js
new file mode 100644
index 00000000..c4ad9e59
--- /dev/null
+++ b/ext/js/settings/dictionary-import-controller.js
@@ -0,0 +1,346 @@
+/*
+ * 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
+ * api
+ */
+
+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 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);
+ 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) {
+ yomichan.logError(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/settings/extension-keyboard-shortcuts-controller.js b/ext/js/settings/extension-keyboard-shortcuts-controller.js
new file mode 100644
index 00000000..9c930703
--- /dev/null
+++ b/ext/js/settings/extension-keyboard-shortcuts-controller.js
@@ -0,0 +1,292 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/generic-setting-controller.js b/ext/js/settings/generic-setting-controller.js
new file mode 100644
index 00000000..7d6fc2e6
--- /dev/null
+++ b/ext/js/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/settings/keyboard-mouse-input-field.js b/ext/js/settings/keyboard-mouse-input-field.js
new file mode 100644
index 00000000..09477519
--- /dev/null
+++ b/ext/js/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/settings/keyboard-shortcuts-controller.js b/ext/js/settings/keyboard-shortcuts-controller.js
new file mode 100644
index 00000000..0dcfa2ee
--- /dev/null
+++ b/ext/js/settings/keyboard-shortcuts-controller.js
@@ -0,0 +1,368 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/main.js b/ext/js/settings/main.js
new file mode 100644
index 00000000..9786d196
--- /dev/null
+++ b/ext/js/settings/main.js
@@ -0,0 +1,111 @@
+/*
+ * 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
+ * api
+ */
+
+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 api.getEnvironmentInfo();
+ document.documentElement.dataset.browser = browser;
+ document.documentElement.dataset.operatingSystem = platform.os;
+}
+
+
+(async () => {
+ try {
+ api.forwardLogsToBackend();
+ await yomichan.backendReady();
+
+ setupEnvironmentInfo();
+ showExtensionInformation();
+
+ const optionsFull = await 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) {
+ yomichan.logError(e);
+ }
+})();
diff --git a/ext/js/settings/mecab-controller.js b/ext/js/settings/mecab-controller.js
new file mode 100644
index 00000000..ff2a4a66
--- /dev/null
+++ b/ext/js/settings/mecab-controller.js
@@ -0,0 +1,67 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/modal-controller.js b/ext/js/settings/modal-controller.js
new file mode 100644
index 00000000..fe4f911b
--- /dev/null
+++ b/ext/js/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/settings/modal-jquery.js b/ext/js/settings/modal-jquery.js
new file mode 100644
index 00000000..8c69ae6d
--- /dev/null
+++ b/ext/js/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/settings/modal.js b/ext/js/settings/modal.js
new file mode 100644
index 00000000..2ef49540
--- /dev/null
+++ b/ext/js/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/settings/nested-popups-controller.js b/ext/js/settings/nested-popups-controller.js
new file mode 100644
index 00000000..1ebc7389
--- /dev/null
+++ b/ext/js/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/settings/permissions-toggle-controller.js b/ext/js/settings/permissions-toggle-controller.js
new file mode 100644
index 00000000..f80e7585
--- /dev/null
+++ b/ext/js/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/settings/pitch-accents-preview-main.js b/ext/js/settings/pitch-accents-preview-main.js
new file mode 100644
index 00000000..f292170a
--- /dev/null
+++ b/ext/js/settings/pitch-accents-preview-main.js
@@ -0,0 +1,33 @@
+/*
+ * 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 {
+ const displayGenerator = new DisplayGenerator({
+ japaneseUtil: null,
+ mediaLoader: null
+ });
+ await displayGenerator.prepare();
+ displayGenerator.preparePitchAccents();
+ } catch (e) {
+ yomichan.logError(e);
+ }
+})();
diff --git a/ext/js/settings/popup-preview-controller.js b/ext/js/settings/popup-preview-controller.js
new file mode 100644
index 00000000..f98b0679
--- /dev/null
+++ b/ext/js/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/settings/popup-preview-frame-main.js b/ext/js/settings/popup-preview-frame-main.js
new file mode 100644
index 00000000..dcbc0d96
--- /dev/null
+++ b/ext/js/settings/popup-preview-frame-main.js
@@ -0,0 +1,44 @@
+/*
+ * 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
+ * api
+ */
+
+(async () => {
+ try {
+ api.forwardLogsToBackend();
+
+ const {tabId, frameId} = await 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) {
+ yomichan.logError(e);
+ }
+})();
diff --git a/ext/js/settings/popup-preview-frame.js b/ext/js/settings/popup-preview-frame.js
new file mode 100644
index 00000000..56100fb3
--- /dev/null
+++ b/ext/js/settings/popup-preview-frame.js
@@ -0,0 +1,233 @@
+/*
+ * 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
+ * api
+ * 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 = api.optionsGet.bind(api);
+ 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/settings/popup-window-controller.js b/ext/js/settings/popup-window-controller.js
new file mode 100644
index 00000000..cc83db68
--- /dev/null
+++ b/ext/js/settings/popup-window-controller.js
@@ -0,0 +1,38 @@
+/*
+ * 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
+ * api
+ */
+
+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 api.getOrCreateSearchPopup({focus: true});
+ }
+}
diff --git a/ext/js/settings/profile-conditions-ui.js b/ext/js/settings/profile-conditions-ui.js
new file mode 100644
index 00000000..5fda1dc0
--- /dev/null
+++ b/ext/js/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/settings/profile-controller.js b/ext/js/settings/profile-controller.js
new file mode 100644
index 00000000..914fc679
--- /dev/null
+++ b/ext/js/settings/profile-controller.js
@@ -0,0 +1,698 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/scan-inputs-controller.js b/ext/js/settings/scan-inputs-controller.js
new file mode 100644
index 00000000..eb179c6a
--- /dev/null
+++ b/ext/js/settings/scan-inputs-controller.js
@@ -0,0 +1,310 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/scan-inputs-simple-controller.js b/ext/js/settings/scan-inputs-simple-controller.js
new file mode 100644
index 00000000..01f044c2
--- /dev/null
+++ b/ext/js/settings/scan-inputs-simple-controller.js
@@ -0,0 +1,247 @@
+/*
+ * 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
+ * api
+ */
+
+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 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/settings/secondary-search-dictionary-controller.js b/ext/js/settings/secondary-search-dictionary-controller.js
new file mode 100644
index 00000000..2fb3de67
--- /dev/null
+++ b/ext/js/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/settings/sentence-termination-characters-controller.js b/ext/js/settings/sentence-termination-characters-controller.js
new file mode 100644
index 00000000..d62771ec
--- /dev/null
+++ b/ext/js/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/settings/settings-controller.js b/ext/js/settings/settings-controller.js
new file mode 100644
index 00000000..11a9435c
--- /dev/null
+++ b/ext/js/settings/settings-controller.js
@@ -0,0 +1,210 @@
+/*
+ * 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
+ * api
+ */
+
+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 api.optionsGet(optionsContext);
+ }
+
+ async getOptionsFull() {
+ return await api.optionsGetFull();
+ }
+
+ async setAllSettings(value) {
+ const profileIndex = value.profileCurrent;
+ await 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 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 api.getSettings(targets);
+ }
+
+ async _modifySettings(targets, extraFields) {
+ targets = this._setupTargets(targets, extraFields);
+ return await 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/settings/settings-display-controller.js b/ext/js/settings/settings-display-controller.js
new file mode 100644
index 00000000..9d3e5459
--- /dev/null
+++ b/ext/js/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/settings/settings-main.js b/ext/js/settings/settings-main.js
new file mode 100644
index 00000000..24248110
--- /dev/null
+++ b/ext/js/settings/settings-main.js
@@ -0,0 +1,156 @@
+/*
+ * 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
+ * api
+ */
+
+async function setupEnvironmentInfo() {
+ const {manifest_version: manifestVersion} = chrome.runtime.getManifest();
+ const {browser, platform} = await 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();
+
+ api.forwardLogsToBackend();
+ await yomichan.prepare();
+
+ setupEnvironmentInfo();
+
+ const optionsFull = await 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) {
+ yomichan.logError(e);
+ }
+})();
diff --git a/ext/js/settings/status-footer.js b/ext/js/settings/status-footer.js
new file mode 100644
index 00000000..c03e6775
--- /dev/null
+++ b/ext/js/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/settings/storage-controller.js b/ext/js/settings/storage-controller.js
new file mode 100644
index 00000000..c27c8690
--- /dev/null
+++ b/ext/js/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/settings/translation-text-replacements-controller.js b/ext/js/settings/translation-text-replacements-controller.js
new file mode 100644
index 00000000..8d13f7e9
--- /dev/null
+++ b/ext/js/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;
+ }
+}