diff --git a/ext/css/settings.css b/ext/css/settings.css index 6bc06bf7..9701aa56 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -2071,10 +2071,16 @@ button.hotkey-list-item-enabled-button[data-scope-count='0'] { .hotkey-list-item-enabled-button-label>.checkbox { margin-right: 0.5em; } -.hotkey-list-item-action-argument { +.hotkey-list-item-action-argument-container { margin-left: 0.375em; flex: 1 1 auto; - visibility: hidden; + display: flex; + flex-flow: row nowrap; + align-items: stretch; + width: var(--input-width-large); +} +.hotkey-argument-label { + margin-right: 0.25em; } .hotkey-list-item-flex-row { display: flex; diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index c9d9be6d..89e2d361 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -988,6 +988,7 @@ "type": "object", "required": [ "action", + "argument", "key", "modifiers", "scopes", @@ -998,6 +999,10 @@ "type": "string", "default": "" }, + "argument": { + "type": "string", + "default": "" + }, "key": { "type": ["string", "null"], "default": null @@ -1026,22 +1031,22 @@ } }, "default": [ - {"action": "close", "key": "Escape", "modifiers": [], "scopes": ["popup"], "enabled": true}, - {"action": "focusSearchBox", "key": "Escape", "modifiers": [], "scopes": ["search"], "enabled": true}, - {"action": "previousEntry3", "key": "PageUp", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "nextEntry3", "key": "PageDown", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "lastEntry", "key": "End", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "firstEntry", "key": "Home", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "previousEntry", "key": "ArrowUp", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "nextEntry", "key": "ArrowDown", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "historyBackward", "key": "KeyB", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "historyForward", "key": "KeyF", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "addNoteKanji", "key": "KeyK", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "addNoteTermKanji", "key": "KeyE", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "addNoteTermKana", "key": "KeyR", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "playAudio", "key": "KeyP", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "viewNote", "key": "KeyV", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, - {"action": "copyHostSelection", "key": "KeyC", "modifiers": ["ctrl"], "scopes": ["popup"], "enabled": true} + {"action": "close", "argument": "", "key": "Escape", "modifiers": [], "scopes": ["popup"], "enabled": true}, + {"action": "focusSearchBox", "argument": "", "key": "Escape", "modifiers": [], "scopes": ["search"], "enabled": true}, + {"action": "previousEntry", "argument": "3", "key": "PageUp", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "nextEntry", "argument": "3", "key": "PageDown", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "lastEntry", "argument": "", "key": "End", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "firstEntry", "argument": "", "key": "Home", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "previousEntry", "argument": "1", "key": "ArrowUp", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "nextEntry", "argument": "1", "key": "ArrowDown", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "historyBackward", "argument": "", "key": "KeyB", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "historyForward", "argument": "", "key": "KeyF", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "addNoteKanji", "argument": "", "key": "KeyK", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "addNoteTermKanji", "argument": "", "key": "KeyE", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "addNoteTermKana", "argument": "", "key": "KeyR", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "playAudio", "argument": "", "key": "KeyP", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "viewNote", "argument": "", "key": "KeyV", "modifiers": ["alt"], "scopes": ["popup", "search"], "enabled": true}, + {"action": "copyHostSelection", "argument": "", "key": "KeyC", "modifiers": ["ctrl"], "scopes": ["popup"], "enabled": true} ] } } diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index fd62a558..5b2e2bd3 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -754,8 +754,32 @@ class OptionsUtil { // Version 10 changes: // Removed global option useSettingsV2. // Added part-of-speech field template. + // Added an argument to hotkey inputs. await this._applyAnkiFieldTemplatesPatch(options, '/data/templates/anki-field-templates-upgrade-v10.handlebars'); delete options.global.useSettingsV2; + for (const profile of options.profiles) { + for (const hotkey of profile.options.inputs.hotkeys) { + switch (hotkey.action) { + case 'previousEntry': + hotkey.argument = '1'; + break; + case 'previousEntry3': + hotkey.action = 'previousEntry'; + hotkey.argument = '3'; + break; + case 'nextEntry': + hotkey.argument = '1'; + break; + case 'nextEntry3': + hotkey.action = 'nextEntry'; + hotkey.argument = '3'; + break; + default: + hotkey.argument = ''; + break; + } + } + } return options; } } diff --git a/ext/js/display/display.js b/ext/js/display/display.js index 7cc3b437..b0805aca 100644 --- a/ext/js/display/display.js +++ b/ext/js/display/display.js @@ -115,10 +115,8 @@ class Display extends EventDispatcher { this._hotkeyHandler.registerActions([ ['close', () => { this._onHotkeyClose(); }], - ['nextEntry', () => { this._focusEntry(this._index + 1, true); }], - ['nextEntry3', () => { this._focusEntry(this._index + 3, true); }], - ['previousEntry', () => { this._focusEntry(this._index - 1, true); }], - ['previousEntry3', () => { this._focusEntry(this._index - 3, true); }], + ['nextEntry', this._onHotkeyActionMoveRelative.bind(this, 1)], + ['previousEntry', this._onHotkeyActionMoveRelative.bind(this, -1)], ['lastEntry', () => { this._focusEntry(this._definitions.length - 1, true); }], ['firstEntry', () => { this._focusEntry(0, true); }], ['historyBackward', () => { this._sourceTermView(); }], @@ -1825,6 +1823,13 @@ class Display extends EventDispatcher { this.close(); } + _onHotkeyActionMoveRelative(sign, argument) { + let count = Number.parseInt(argument, 10); + if (!Number.isFinite(count)) { count = 1; } + count = Math.max(0, Math.floor(count)); + this._focusEntry(this._index + count * sign, true); + } + _closeAllPopupMenus() { for (const popupMenu of PopupMenu.openMenus) { popupMenu.close(); diff --git a/ext/js/dom/dom-data-binder.js b/ext/js/dom/dom-data-binder.js index 292b2f67..65e91233 100644 --- a/ext/js/dom/dom-data-binder.js +++ b/ext/js/dom/dom-data-binder.js @@ -210,7 +210,7 @@ class DOMDataBinder { static convertToNumber(value, constraints) { value = parseFloat(value); - if (!Number.isFinite(value)) { return 0; } + if (!Number.isFinite(value)) { value = 0; } let {min, max, step} = constraints; min = DOMDataBinder.convertToNumberOrNull(min); diff --git a/ext/js/input/hotkey-handler.js b/ext/js/input/hotkey-handler.js index d3b82d48..8388302d 100644 --- a/ext/js/input/hotkey-handler.js +++ b/ext/js/input/hotkey-handler.js @@ -166,12 +166,12 @@ class HotkeyHandler extends EventDispatcher { } _invokeHandlers(modifiers, hotkeyInfo) { - for (const {modifiers: handlerModifiers, action} of hotkeyInfo.handlers) { + for (const {modifiers: handlerModifiers, action, argument} of hotkeyInfo.handlers) { if (!this._areSame(handlerModifiers, modifiers)) { continue; } const actionHandler = this._actions.get(action); if (typeof actionHandler !== 'undefined') { - const result = actionHandler(); + const result = actionHandler(argument); if (result !== false) { return true; } @@ -196,7 +196,7 @@ class HotkeyHandler extends EventDispatcher { this._hotkeys.clear(); for (const [scope, registrations] of this._hotkeyRegistrations.entries()) { - for (const {action, key, modifiers, scopes, enabled} of registrations) { + for (const {action, argument, key, modifiers, scopes, enabled} of registrations) { if (!(enabled && key !== null && action !== '' && scopes.includes(scope))) { continue; } let hotkeyInfo = this._hotkeys.get(key); @@ -205,7 +205,7 @@ class HotkeyHandler extends EventDispatcher { this._hotkeys.set(key, hotkeyInfo); } - hotkeyInfo.handlers.push({modifiers: new Set(modifiers), action}); + hotkeyInfo.handlers.push({modifiers: new Set(modifiers), action, argument}); } } this._updateEventHandlers(); diff --git a/ext/js/pages/settings/keyboard-shortcuts-controller.js b/ext/js/pages/settings/keyboard-shortcuts-controller.js index 514928d7..2f636541 100644 --- a/ext/js/pages/settings/keyboard-shortcuts-controller.js +++ b/ext/js/pages/settings/keyboard-shortcuts-controller.js @@ -16,6 +16,7 @@ */ /* global + * DOMDataBinder * KeyboardMouseInputField */ @@ -30,6 +31,26 @@ class KeyboardShortcutController { this._emptyIndicator = null; this._stringComparer = new Intl.Collator('en-US'); // Invariant locale this._scrollContainer = null; + this._actionDetails = new Map([ + ['', {scopes: new Set()}], + ['close', {scopes: new Set(['popup', 'search'])}], + ['focusSearchBox', {scopes: new Set(['search'])}], + ['nextEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}], + ['previousEntry', {scopes: new Set(['popup', 'search']), argument: {template: 'hotkey-argument-move-offset', default: '1'}}], + ['lastEntry', {scopes: new Set(['popup', 'search'])}], + ['firstEntry', {scopes: new Set(['popup', 'search'])}], + ['nextEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}], + ['previousEntryDifferentDictionary', {scopes: new Set(['popup', 'search'])}], + ['historyBackward', {scopes: new Set(['popup', 'search'])}], + ['historyForward', {scopes: new Set(['popup', 'search'])}], + ['addNoteKanji', {scopes: new Set(['popup', 'search'])}], + ['addNoteTermKanji', {scopes: new Set(['popup', 'search'])}], + ['addNoteTermKana', {scopes: new Set(['popup', 'search'])}], + ['viewNote', {scopes: new Set(['popup', 'search'])}], + ['playAudio', {scopes: new Set(['popup', 'search'])}], + ['copyHostSelection', {scopes: new Set(['popup'])}], + ['scanSelectedText', {scopes: new Set(['web'])}] + ]); } get settingsController() { @@ -96,6 +117,10 @@ class KeyboardShortcutController { return defaultOptions.profiles[0].options.inputs.hotkeys; } + getActionDetails(action) { + return this._actionDetails.get(action); + } + // Private _onOptionsChanged({options}) { @@ -134,6 +159,7 @@ class KeyboardShortcutController { async _addNewEntry() { const newEntry = { action: '', + argument: '', key: null, modifiers: [], scopes: ['popup', 'search'], @@ -169,6 +195,9 @@ class KeyboardShortcutHotkeyEntry { this._enabledButton = null; this._scopeMenu = null; this._scopeMenuEventListeners = new EventListenerCollection(); + this._argumentContainer = null; + this._argumentInput = null; + this._argumentEventListeners = new EventListenerCollection(); } prepare() { @@ -183,6 +212,7 @@ class KeyboardShortcutHotkeyEntry { this._actionSelect = action; this._enabledButton = enabledButton; + this._argumentContainer = node.querySelector('.hotkey-list-item-action-argument-container'); this._inputField = new KeyboardMouseInputField(input, null, this._os); this._inputField.prepare(this._data.key, this._data.modifiers, false, true); @@ -193,6 +223,7 @@ class KeyboardShortcutHotkeyEntry { enabledToggle.dataset.setting = `${this._basePath}.enabled`; this._updateScopesButton(); + this._updateActionArgument(); this._eventListeners.addEventListener(scopesButton, 'menuOpen', this._onScopesMenuOpen.bind(this)); this._eventListeners.addEventListener(scopesButton, 'menuClose', this._onScopesMenuClose.bind(this)); @@ -205,6 +236,7 @@ class KeyboardShortcutHotkeyEntry { this._eventListeners.removeAllEventListeners(); this._inputField.cleanup(); this._clearScopeMenu(); + this._clearArgumentEventListeners(); if (this._node.parentNode !== null) { this._node.parentNode.removeChild(this._node); } @@ -228,6 +260,11 @@ class KeyboardShortcutHotkeyEntry { _onScopesMenuOpen(e) { const {menu} = e.detail; + const validScopes = this._getValidScopesForAction(this._data.action); + if (validScopes.size === 0) { + menu.close(); + return; + } this._scopeMenu = menu; this._updateScopeMenuItems(menu); this._updateDisplay(menu.containerNode); // Fix a animation issue due to changing checkbox values @@ -260,6 +297,21 @@ class KeyboardShortcutHotkeyEntry { this._setAction(value); } + _onArgumentValueChange(template, e) { + const node = e.currentTarget; + const value = this._getArgumentInputValue(node); + let newValue = value; + switch (template) { + case 'hotkey-argument-move-offset': + newValue = `${DOMDataBinder.convertToNumber(value, node)}`; + break; + } + if (value !== newValue) { + this._setArgumentInputValue(node, newValue); + } + this._setArgument(newValue); + } + async _delete() { this._parent.deleteEntry(this._index); } @@ -294,13 +346,13 @@ class KeyboardShortcutHotkeyEntry { scopes.splice(index, 1); } - this._updateScopesButton(); - await this._modifyProfileSettings([{ action: 'set', path: `${this._basePath}.scopes`, value: scopes }]); + + this._updateScopesButton(); } async _modifyProfileSettings(targets) { @@ -326,58 +378,81 @@ class KeyboardShortcutHotkeyEntry { } async _setAction(value) { - const targets = [{ - action: 'set', - path: `${this._basePath}.action`, - value - }]; - - this._data.action = value; + const validScopesOld = this._getValidScopesForAction(this._data.action); const scopes = this._data.scopes; - const validScopes = this._getValidScopesForAction(value); - if (validScopes !== null) { - let changed = false; + + let details = this._parent.getActionDetails(value); + if (typeof details === 'undefined') { details = {}; } + + let validScopes = details.scopes; + if (typeof validScopes === 'undefined') { validScopes = new Set(); } + + const {argument: argumentDetails} = details; + let defaultArgument = typeof argumentDetails !== 'undefined' ? argumentDetails.default : ''; + if (typeof defaultArgument !== 'string') { defaultArgument = ''; } + + this._data.action = value; + this._data.argument = defaultArgument; + + let scopesChanged = false; + if ((validScopesOld !== null ? validScopesOld.size : 0) === scopes.length) { + scopes.length = 0; + scopesChanged = true; + } else { for (let i = 0, ii = scopes.length; i < ii; ++i) { if (!validScopes.has(scopes[i])) { scopes.splice(i, 1); --i; --ii; - changed = true; + scopesChanged = true; } } - if (changed) { - if (scopes.length === 0) { - scopes.push(...validScopes); - } - targets.push({ - action: 'set', - path: `${this._basePath}.scopes`, - value: scopes - }); - this._updateCheckboxStates(); - } + } + if (scopesChanged && scopes.length === 0) { + scopes.push(...validScopes); } - await this._modifyProfileSettings(targets); + await this._modifyProfileSettings([ + { + action: 'set', + path: `${this._basePath}.action`, + value: this._data.action + }, + { + action: 'set', + path: `${this._basePath}.argument`, + value: this._data.argument + }, + { + action: 'set', + path: `${this._basePath}.scopes`, + value: this._data.scopes + } + ]); - this._updateCheckboxVisibility(); + this._updateScopesButton(); + this._updateScopesMenu(); + this._updateActionArgument(); } - _updateCheckboxStates() { - if (this._scopeMenu === null) { return; } - this._updateScopeMenuItems(this._scopeMenu); + async _setArgument(value) { + this._data.argument = value; + await this._modifyProfileSettings([{ + action: 'set', + path: `${this._basePath}.argument`, + value + }]); } - _updateCheckboxVisibility() { + _updateScopesMenu() { if (this._scopeMenu === null) { return; } this._updateScopeMenuItems(this._scopeMenu); } _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); + const details = this._parent.getActionDetails(action); + return typeof details !== 'undefined' ? details.scopes : null; } _updateScopeMenuItems(menu) { @@ -419,4 +494,39 @@ class KeyboardShortcutHotkeyEntry { getComputedStyle(node).getPropertyValue('display'); style.display = display; } + + _updateActionArgument() { + this._clearArgumentEventListeners(); + + const {action, argument} = this._data; + const details = this._parent.getActionDetails(action); + const {argument: argumentDetails} = typeof details !== 'undefined' ? details : {}; + + this._argumentContainer.textContent = ''; + if (typeof argumentDetails !== 'undefined') { + const {template} = argumentDetails; + const node = this._parent.settingsController.instantiateTemplate(template); + const inputSelector = '.hotkey-argument-input'; + const inputNode = node.matches(inputSelector) ? node : node.querySelector(inputSelector); + if (inputNode !== null) { + this._setArgumentInputValue(inputNode, argument); + this._argumentInput = inputNode; + this._argumentEventListeners.addEventListener(inputNode, 'change', this._onArgumentValueChange.bind(this, template), false); + } + this._argumentContainer.appendChild(node); + } + } + + _clearArgumentEventListeners() { + this._argumentEventListeners.removeAllEventListeners(); + this._argumentInput = null; + } + + _getArgumentInputValue(node) { + return node.value; + } + + _setArgumentInputValue(node, value) { + node.value = value; + } } diff --git a/ext/settings.html b/ext/settings.html index b99cf0c3..12526442 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -3142,28 +3142,26 @@
Action:
- +
@@ -3205,6 +3203,11 @@ + + diff --git a/test/test-options-util.js b/test/test-options-util.js index 15f1481c..7b9e6e4b 100644 --- a/test/test-options-util.js +++ b/test/test-options-util.js @@ -441,22 +441,22 @@ function createProfileOptionsUpdatedTestData1() { }, inputs: { hotkeys: [ - {action: 'close', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true}, - {action: 'focusSearchBox', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true}, - {action: 'previousEntry3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'nextEntry3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'lastEntry', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'firstEntry', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'previousEntry', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'nextEntry', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'historyBackward', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'historyForward', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'addNoteKanji', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'addNoteTermKanji', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'addNoteTermKana', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'playAudio', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'viewNote', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, - {action: 'copyHostSelection', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true} + {action: 'close', argument: '', key: 'Escape', modifiers: [], scopes: ['popup'], enabled: true}, + {action: 'focusSearchBox', argument: '', key: 'Escape', modifiers: [], scopes: ['search'], enabled: true}, + {action: 'previousEntry', argument: '3', key: 'PageUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry', argument: '3', key: 'PageDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'lastEntry', argument: '', key: 'End', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'firstEntry', argument: '', key: 'Home', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'previousEntry', argument: '1', key: 'ArrowUp', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'nextEntry', argument: '1', key: 'ArrowDown', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyBackward', argument: '', key: 'KeyB', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'historyForward', argument: '', key: 'KeyF', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteKanji', argument: '', key: 'KeyK', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKanji', argument: '', key: 'KeyE', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'addNoteTermKana', argument: '', key: 'KeyR', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'playAudio', argument: '', key: 'KeyP', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'viewNote', argument: '', key: 'KeyV', modifiers: ['alt'], scopes: ['popup', 'search'], enabled: true}, + {action: 'copyHostSelection', argument: '', key: 'KeyC', modifiers: ['ctrl'], scopes: ['popup'], enabled: true} ] }, popupWindow: {