diff --git a/ext/css/material.css b/ext/css/material.css index efa5a730..703f1268 100644 --- a/ext/css/material.css +++ b/ext/css/material.css @@ -177,6 +177,15 @@ } +/* Text styles */ +.light { + color: var(--text-color-light2); +} +.danger-text { + color: var(--danger-color); +} + + /* Icons */ .icon { --icon-image: none; diff --git a/ext/css/settings.css b/ext/css/settings.css index 9701aa56..e2485925 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -158,15 +158,9 @@ pre { /* Text styles */ -.light { - color: var(--text-color-light2); -} .warning-text { color: var(--warning-color); } -.danger-text { - color: var(--danger-color); -} /* Headings */ diff --git a/ext/js/display/display.js b/ext/js/display/display.js index 3ae55da0..ee2448d6 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -27,6 +27,7 @@ * Frontend * HotkeyHelpController * MediaLoader + * OptionToggleHotkeyHandler * PopupFactory * PopupMenu * QueryParser @@ -112,6 +113,7 @@ class Display extends EventDispatcher { this._ankiNoteNotification = null; this._ankiNoteNotificationEventListeners = null; this._queryPostProcessor = null; + this._optionToggleHotkeyHandler = new OptionToggleHotkeyHandler(this); this._hotkeyHandler.registerActions([ ['close', () => { this._onHotkeyClose(); }], @@ -201,6 +203,10 @@ class Display extends EventDispatcher { return this._parentPopupId; } + get notificationContainer() { + return this._footerNotificationContainer; + } + async prepare() { // State setup const {documentElement} = document; @@ -213,6 +219,7 @@ class Display extends EventDispatcher { this._displayAudio.prepare(); this._queryParser.prepare(); this._history.prepare(); + this._optionToggleHotkeyHandler.prepare(); // Event setup this._history.on('stateChanged', this._onStateChanged.bind(this)); diff --git a/ext/js/display/option-toggle-hotkey-handler.js b/ext/js/display/option-toggle-hotkey-handler.js new file mode 100644 index 00000000..fae17f8d --- /dev/null +++ b/ext/js/display/option-toggle-hotkey-handler.js @@ -0,0 +1,164 @@ +/* + * 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 . + */ + +/* global + * DisplayNotification + */ + +class OptionToggleHotkeyHandler { + constructor(display) { + this._display = display; + this._notification = null; + this._notificationHideTimer = null; + this._notificationHideTimeout = 5000; + } + + get notificationHideTimeout() { + return this._notificationHideTimeout; + } + + set notificationHideTimeout(value) { + this._notificationHideTimeout = value; + } + + prepare() { + this._display.hotkeyHandler.registerActions([ + ['toggleOption', this._onHotkeyActionToggleOption.bind(this)] + ]); + } + + // Private + + _onHotkeyActionToggleOption(argument) { + this._toggleOption(argument); + } + + async _toggleOption(path) { + let value; + try { + const optionsContext = this._display.getOptionsContext(); + + const result = (await yomichan.api.getSettings([{ + scope: 'profile', + path, + optionsContext + }]))[0]; + const {error} = result; + if (typeof error !== 'undefined') { + throw deserializeError(error); + } + + value = result.result; + if (typeof value !== 'boolean') { + throw new Error(`Option value of type ${typeof value} cannot be toggled`); + } + + value = !value; + + const result2 = (await yomichan.api.modifySettings([{ + scope: 'profile', + action: 'set', + path, + value, + optionsContext + }]))[0]; + const {error: error2} = result2; + if (typeof error2 !== 'undefined') { + throw deserializeError(error2); + } + + this._showNotification(this._createSuccessMessage(path, value), true); + } catch (e) { + this._showNotification(this._createErrorMessage(path, e), false); + } + } + + _createSuccessMessage(path, value) { + const fragment = document.createDocumentFragment(); + const n1 = document.createElement('em'); + n1.textContent = path; + const n2 = document.createElement('strong'); + n2.textContent = value; + fragment.appendChild(document.createTextNode('Option ')); + fragment.appendChild(n1); + fragment.appendChild(document.createTextNode(' changed to ')); + fragment.appendChild(n2); + return fragment; + } + + _createErrorMessage(path, error) { + let message; + try { + ({message} = error); + } catch (e) { + // NOP + } + if (typeof message !== 'string') { + message = `${error}`; + } + + const fragment = document.createDocumentFragment(); + const n1 = document.createElement('em'); + n1.textContent = path; + const n2 = document.createElement('div'); + n2.textContent = message; + n2.className = 'danger-text'; + fragment.appendChild(document.createTextNode('Failed to toggle option ')); + fragment.appendChild(n1); + fragment.appendChild(document.createTextNode(': ')); + fragment.appendChild(n2); + return fragment; + } + + _showNotification(message, autoClose) { + if (this._notification === null) { + const node = this._display.displayGenerator.createEmptyFooterNotification(); + node.addEventListener('click', this._onNotificationClick.bind(this), false); + this._notification = new DisplayNotification(this._display.notificationContainer, node); + } + + this._notification.setContent(message); + this._notification.open(); + + this._stopHideNotificationTimer(); + if (autoClose) { + this._notificationHideTimer = setTimeout(this._onNotificationHideTimeout.bind(this), this._notificationHideTimeout); + } + } + + _hideNotification(animate) { + if (this._notification === null) { return; } + this._notification.close(animate); + this._stopHideNotificationTimer(); + } + + _stopHideNotificationTimer() { + if (this._notificationHideTimer !== null) { + clearTimeout(this._notificationHideTimer); + this._notificationHideTimer = null; + } + } + + _onNotificationHideTimeout() { + this._notificationHideTimer = null; + this._hideNotification(true); + } + + _onNotificationClick() { + this._stopHideNotificationTimer(); + } +} diff --git a/ext/js/pages/settings/keyboard-shortcuts-controller.js b/ext/js/pages/settings/keyboard-shortcuts-controller.js index 7dbf5aa2..aeff15b6 100644 --- a/ext/js/pages/settings/keyboard-shortcuts-controller.js +++ b/ext/js/pages/settings/keyboard-shortcuts-controller.js @@ -18,6 +18,7 @@ /* global * DOMDataBinder * KeyboardMouseInputField + * ObjectPropertyAccessor */ class KeyboardShortcutController { @@ -50,7 +51,8 @@ class KeyboardShortcutController { ['playAudio', {scopes: new Set(['popup', 'search'])}], ['playAudioFromSource', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-audio-source', default: 'jpod101'}}], ['copyHostSelection', {scopes: new Set(['popup'])}], - ['scanSelectedText', {scopes: new Set(['web'])}] + ['scanSelectedText', {scopes: new Set(['web'])}], + ['toggleOption', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-setting-path', default: ''}}] ]); } @@ -316,14 +318,13 @@ class KeyboardShortcutHotkeyEntry { _onArgumentValueChange(template, e) { const node = e.currentTarget; - const value = this._getArgumentInputValue(node); - let newValue = value; + let value = this._getArgumentInputValue(node); switch (template) { case 'hotkey-argument-move-offset': - newValue = `${DOMDataBinder.convertToNumber(value, node)}`; + value = `${DOMDataBinder.convertToNumber(value, node)}`; break; } - this._setArgument(newValue); + this._setArgument(value); } async _delete() { @@ -467,6 +468,8 @@ class KeyboardShortcutHotkeyEntry { this._setArgumentInputValue(node, value); } + this._updateArgumentInputValidity(); + await this._modifyProfileSettings([{ action: 'set', path: `${this._basePath}.argument`, @@ -540,6 +543,7 @@ class KeyboardShortcutHotkeyEntry { if (inputNode !== null) { this._setArgumentInputValue(inputNode, argument); this._argumentInput = inputNode; + this._updateArgumentInputValidity(); this._argumentEventListeners.addEventListener(inputNode, 'change', this._onArgumentValueChange.bind(this, template), false); } this._argumentContainer.appendChild(node); @@ -558,4 +562,41 @@ class KeyboardShortcutHotkeyEntry { _setArgumentInputValue(node, value) { node.value = value; } + + async _updateArgumentInputValidity() { + if (this._argumentInput === null) { return; } + + let okay = true; + const {action, argument} = this._data; + const details = this._parent.getActionDetails(action); + const {argument: argumentDetails} = typeof details !== 'undefined' ? details : {}; + + if (typeof argumentDetails !== 'undefined') { + const {template} = argumentDetails; + switch (template) { + case 'hotkey-argument-setting-path': + okay = await this._isHotkeyArgumentSettingPathValid(argument); + break; + } + } + + this._argumentInput.dataset.invalid = `${!okay}`; + } + + async _isHotkeyArgumentSettingPathValid(path) { + if (path.length === 0) { return true; } + + const options = await this._parent.settingsController.getOptions(); + const accessor = new ObjectPropertyAccessor(options); + const pathArray = ObjectPropertyAccessor.getPathArray(path); + try { + const value = accessor.get(pathArray, pathArray.length); + if (typeof value === 'boolean') { + return true; + } + } catch (e) { + // NOP + } + return false; + } } diff --git a/ext/popup.html b/ext/popup.html index 78e89997..36cff420 100644 --- a/ext/popup.html +++ b/ext/popup.html @@ -105,6 +105,7 @@ + diff --git a/ext/search.html b/ext/search.html index 48abb8b7..4ef8860f 100644 --- a/ext/search.html +++ b/ext/search.html @@ -89,6 +89,7 @@ + diff --git a/ext/settings.html b/ext/settings.html index e9f27751..3de183db 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -3172,6 +3172,7 @@ +
@@ -3221,9 +3222,14 @@ + +