diff options
author | siikamiika <siikamiika@users.noreply.github.com> | 2020-05-03 04:39:24 +0300 |
---|---|---|
committer | GitHub <noreply@github.com> | 2020-05-03 04:39:24 +0300 |
commit | 77b744e675f8abf17ff5e8433f4f1717e0c9ffb5 (patch) | |
tree | 037cb5c45dc1f9041130ea913d120e7f1526b1e1 /ext | |
parent | acfdaa4f483790cf3d70a2c1a59d82a422ebed1f (diff) |
Modifier key profile condition (#487)
* update Frontend options on modifier change
* add modifier key profile condition
* use select element for modifier condition value
* support "is" and "is not" modifier key conditions
* use plural
* remove dead null check
it's never null in that function
* pass element on rather than assigning to this
* rename event
* remove Firefox OS key to Meta detection
* hide Meta from dropdown on Firefox
* move input type
Diffstat (limited to 'ext')
-rw-r--r-- | ext/bg/js/profile-conditions.js | 66 | ||||
-rw-r--r-- | ext/bg/js/search.js | 3 | ||||
-rw-r--r-- | ext/bg/js/settings/conditions-ui.js | 139 | ||||
-rw-r--r-- | ext/bg/settings.html | 5 | ||||
-rw-r--r-- | ext/fg/js/frontend.js | 27 | ||||
-rw-r--r-- | ext/mixed/js/core.js | 6 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 7 | ||||
-rw-r--r-- | ext/mixed/js/dom.js | 14 | ||||
-rw-r--r-- | ext/mixed/js/text-scanner.js | 3 |
9 files changed, 247 insertions, 23 deletions
diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js index a0710bd1..c0f5d3f5 100644 --- a/ext/bg/js/profile-conditions.js +++ b/ext/bg/js/profile-conditions.js @@ -36,6 +36,24 @@ function _profileConditionTestDomainList(url, domainList) { return false; } +const _profileModifierKeys = [ + {optionValue: 'alt', name: 'Alt'}, + {optionValue: 'ctrl', name: 'Ctrl'}, + {optionValue: 'shift', name: 'Shift'} +]; + +if (!hasOwn(window, 'netscape')) { + _profileModifierKeys.push({optionValue: 'meta', name: 'Meta'}); +} + +const _profileModifierValueToName = new Map( + _profileModifierKeys.map(({optionValue, name}) => [optionValue, name]) +); + +const _profileModifierNameToValue = new Map( + _profileModifierKeys.map(({optionValue, name}) => [name, optionValue]) +); + const profileConditionsDescriptor = { popupLevel: { name: 'Popup Level', @@ -100,5 +118,53 @@ const profileConditionsDescriptor = { test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url)) } } + }, + modifierKeys: { + name: 'Modifier Keys', + description: 'Use profile depending on the active modifier keys.', + values: _profileModifierKeys, + defaultOperator: 'are', + operators: { + are: { + name: 'are', + placeholder: 'Press one or more modifier keys here', + defaultValue: '', + type: 'keyMulti', + transform: (optionValue) => optionValue + .split(' + ') + .filter((v) => v.length > 0) + .map((v) => _profileModifierNameToValue.get(v)), + transformReverse: (transformedOptionValue) => transformedOptionValue + .map((v) => _profileModifierValueToName.get(v)) + .join(' + '), + test: ({modifierKeys}, optionValue) => areSetsEqual(new Set(modifierKeys), new Set(optionValue)) + }, + areNot: { + name: 'are not', + placeholder: 'Press one or more modifier keys here', + defaultValue: '', + type: 'keyMulti', + transform: (optionValue) => optionValue + .split(' + ') + .filter((v) => v.length > 0) + .map((v) => _profileModifierNameToValue.get(v)), + transformReverse: (transformedOptionValue) => transformedOptionValue + .map((v) => _profileModifierValueToName.get(v)) + .join(' + '), + test: ({modifierKeys}, optionValue) => !areSetsEqual(new Set(modifierKeys), new Set(optionValue)) + }, + include: { + name: 'include', + type: 'select', + defaultValue: 'alt', + test: ({modifierKeys}, optionValue) => modifierKeys.includes(optionValue) + }, + notInclude: { + name: 'don\'t include', + type: 'select', + defaultValue: 'alt', + test: ({modifierKeys}, optionValue) => !modifierKeys.includes(optionValue) + } + } } }; diff --git a/ext/bg/js/search.js b/ext/bg/js/search.js index b7d2eed8..47d495e6 100644 --- a/ext/bg/js/search.js +++ b/ext/bg/js/search.js @@ -17,6 +17,7 @@ /* global * ClipboardMonitor + * DOM * Display * QueryParser * apiClipboardGet @@ -178,7 +179,7 @@ class DisplaySearch extends Display { } onKeyDown(e) { - const key = Display.getKeyFromEvent(e); + const key = DOM.getKeyFromEvent(e); const ignoreKeys = this._onKeyDownIgnoreKeys; const activeModifierMap = new Map([ diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 84498b42..5b356101 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -16,6 +16,7 @@ */ /* global + * DOM * conditionsNormalizeOptionValue */ @@ -177,7 +178,8 @@ ConditionsUI.Condition = class Condition { this.parent = parent; this.condition = condition; this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container); - this.input = this.container.find('input'); + this.input = this.container.find('.condition-input'); + this.inputInner = null; this.typeSelect = this.container.find('.condition-type'); this.operatorSelect = this.container.find('.condition-operator'); this.removeButton = this.container.find('.condition-remove'); @@ -186,14 +188,13 @@ ConditionsUI.Condition = class Condition { this.updateOperators(); this.updateInput(); - this.input.on('change', this.onInputChanged.bind(this)); this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); this.removeButton.on('click', this.onRemoveClicked.bind(this)); } cleanup() { - this.input.off('change'); + this.inputInner.off('change'); this.typeSelect.off('change'); this.operatorSelect.off('change'); this.removeButton.off('click'); @@ -236,21 +237,48 @@ ConditionsUI.Condition = class Condition { updateInput() { const conditionDescriptors = this.parent.parent.conditionDescriptors; const {type, operator} = this.condition; - const props = new Map([ - ['placeholder', ''], - ['type', 'text'] - ]); const objects = []; + let inputType = null; if (hasOwn(conditionDescriptors, type)) { const conditionDescriptor = conditionDescriptors[type]; objects.push(conditionDescriptor); + if (hasOwn(conditionDescriptor, 'type')) { + inputType = conditionDescriptor.type; + } if (hasOwn(conditionDescriptor.operators, operator)) { const operatorDescriptor = conditionDescriptor.operators[operator]; objects.push(operatorDescriptor); + if (hasOwn(operatorDescriptor, 'type')) { + inputType = operatorDescriptor.type; + } } } + this.input.empty(); + if (inputType === 'select') { + this.inputInner = this.createSelectElement(objects); + } else if (inputType === 'keyMulti') { + this.inputInner = this.createInputKeyMultiElement(objects); + } else { + this.inputInner = this.createInputElement(objects); + } + this.inputInner.appendTo(this.input); + this.inputInner.on('change', this.onInputChanged.bind(this)); + + const {valid} = this.validateValue(this.condition.value); + this.inputInner.toggleClass('is-invalid', !valid); + this.inputInner.val(this.condition.value); + } + + createInputElement(objects) { + const inputInner = ConditionsUI.instantiateTemplate('#condition-input-text-template'); + + const props = new Map([ + ['placeholder', ''], + ['type', 'text'] + ]); + for (const object of objects) { if (hasOwn(object, 'placeholder')) { props.set('placeholder', object.placeholder); @@ -266,12 +294,95 @@ ConditionsUI.Condition = class Condition { } for (const [prop, value] of props.entries()) { - this.input.prop(prop, value); + inputInner.prop(prop, value); } - const {valid} = this.validateValue(this.condition.value); - this.input.toggleClass('is-invalid', !valid); - this.input.val(this.condition.value); + return inputInner; + } + + createInputKeyMultiElement(objects) { + const inputInner = this.createInputElement(objects); + + inputInner.prop('readonly', true); + + let values = []; + for (const object of objects) { + if (hasOwn(object, 'values')) { + values = object.values; + } + } + + const pressedKeyIndices = new Set(); + + const onKeyDown = ({originalEvent}) => { + const pressedKeyEventName = DOM.getKeyFromEvent(originalEvent); + if (pressedKeyEventName === 'Escape' || pressedKeyEventName === 'Backspace') { + pressedKeyIndices.clear(); + inputInner.val(''); + inputInner.change(); + return; + } + + const pressedModifiers = DOM.getActiveModifiers(originalEvent); + // 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 + // hack; only works when Shift and Alt are not pressed + const isMetaKeyChrome = ( + pressedKeyEventName === 'Meta' && + getSetDifference(new Set(['shift', 'alt']), pressedModifiers).size !== 0 + ); + if (isMetaKeyChrome) { + pressedModifiers.add('meta'); + } + + for (const modifier of pressedModifiers) { + const foundIndex = values.findIndex(({optionValue}) => optionValue === modifier); + if (foundIndex !== -1) { + pressedKeyIndices.add(foundIndex); + } + } + + const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(' + '); + inputInner.val(inputValue); + inputInner.change(); + }; + + inputInner.on('keydown', onKeyDown); + + return inputInner; + } + + createSelectElement(objects) { + const inputInner = ConditionsUI.instantiateTemplate('#condition-input-select-template'); + + const data = new Map([ + ['values', []], + ['defaultValue', null] + ]); + + for (const object of objects) { + if (hasOwn(object, 'values')) { + data.set('values', object.values); + } + if (hasOwn(object, 'defaultValue')) { + data.set('defaultValue', object.defaultValue); + } + } + + for (const {optionValue, name} of data.get('values')) { + const option = ConditionsUI.instantiateTemplate('#condition-input-option-template'); + option.attr('value', optionValue); + option.text(name); + option.appendTo(inputInner); + } + + const defaultValue = data.get('defaultValue'); + if (defaultValue !== null) { + inputInner.val(defaultValue); + } + + return inputInner; } validateValue(value) { @@ -291,9 +402,9 @@ ConditionsUI.Condition = class Condition { } onInputChanged() { - const {valid, value} = this.validateValue(this.input.val()); - this.input.toggleClass('is-invalid', !valid); - this.input.val(value); + const {valid, value} = this.validateValue(this.inputInner.val()); + this.inputInner.toggleClass('is-invalid', !valid); + this.inputInner.val(value); this.condition.value = value; this.save(); } diff --git a/ext/bg/settings.html b/ext/bg/settings.html index a0220e96..fc9221f8 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -117,7 +117,7 @@ <div class="input-group-btn"><select class="form-control btn btn-default condition-type"><optgroup label="Type"></optgroup></select></div> <div class="input-group-btn"><select class="form-control btn btn-default condition-operator"><optgroup label="Operator"></optgroup></select></div> <div class="condition-line-break"></div> - <div class="condition-input"><input type="text" class="form-control" /></div> + <div class="condition-input"></div> <div class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div> </div></template> <template id="condition-group-separator-template"><div class="input-group"> @@ -126,6 +126,9 @@ <template id="condition-group-options-template"><div class="condition-group-options"> <button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button> </div></template> + <template id="condition-input-text-template"><input type="text" class="form-control condition-input-inner" /></template> + <template id="condition-input-select-template"><select class="form-control condition-input-inner"></select></template> + <template id="condition-input-option-template"><option></option></template> </div> <div> diff --git a/ext/fg/js/frontend.js b/ext/fg/js/frontend.js index 76ad27e0..d979246d 100644 --- a/ext/fg/js/frontend.js +++ b/ext/fg/js/frontend.js @@ -50,6 +50,9 @@ class Frontend { ); this._textScanner.onSearchSource = this.onSearchSource.bind(this); + this._activeModifiers = new Set(); + this._optionsUpdatePending = false; + this._windowMessageHandlers = new Map([ ['popupClose', () => this._textScanner.clearSelection(false)], ['selectionCopy', () => document.execCommand('copy')] @@ -90,6 +93,7 @@ class Frontend { chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); this._textScanner.on('clearSelection', this.onClearSelection.bind(this)); + this._textScanner.on('activeModifiersChanged', this.onActiveModifiersChanged.bind(this)); this._updateContentScale(); this._broadcastRootPopupInformation(); @@ -173,12 +177,21 @@ class Frontend { } } + async updatePendingOptions() { + if (this._optionsUpdatePending) { + this._optionsUpdatePending = false; + await this.updateOptions(); + } + } + async setTextSource(textSource) { await this.onSearchSource(textSource, 'script'); this._textScanner.setCurrentTextSource(textSource); } async onSearchSource(textSource, cause) { + await this.updatePendingOptions(); + let results = null; try { @@ -254,12 +267,24 @@ class Frontend { onClearSelection({passive}) { this.popup.hide(!passive); this.popup.clearAutoPlayTimer(); + this.updatePendingOptions(); + } + + async onActiveModifiersChanged({modifiers}) { + if (areSetsEqual(modifiers, this._activeModifiers)) { return; } + this._activeModifiers = modifiers; + if (await this.popup.isVisible()) { + this._optionsUpdatePending = true; + return; + } + await this.updateOptions(); } async getOptionsContext() { const url = this._getUrl !== null ? await this._getUrl() : window.location.href; const depth = this.popup.depth; - return {depth, url}; + const modifierKeys = [...this._activeModifiers]; + return {depth, url, modifierKeys}; } _showPopupContent(textSource, optionsContext, type=null, details=null) { diff --git a/ext/mixed/js/core.js b/ext/mixed/js/core.js index fbe9943a..835d9cea 100644 --- a/ext/mixed/js/core.js +++ b/ext/mixed/js/core.js @@ -146,6 +146,12 @@ function getSetIntersection(set1, set2) { return result; } +function getSetDifference(set1, set2) { + return new Set( + [...set1].filter((value) => !set2.has(value)) + ); +} + /* * Async utilities diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js index 32081c70..783af7d8 100644 --- a/ext/mixed/js/display.js +++ b/ext/mixed/js/display.js @@ -338,7 +338,7 @@ class Display { } onKeyDown(e) { - const key = Display.getKeyFromEvent(e); + const key = DOM.getKeyFromEvent(e); const handler = this._onKeyDownHandlers.get(key); if (typeof handler === 'function') { if (handler(e)) { @@ -964,11 +964,6 @@ class Display { return elementRect.top - documentRect.top; } - static getKeyFromEvent(event) { - const key = event.key; - return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); - } - async _getNoteContext() { const documentTitle = await this.getDocumentTitle(); return { diff --git a/ext/mixed/js/dom.js b/ext/mixed/js/dom.js index 31ba33d6..0e8f4462 100644 --- a/ext/mixed/js/dom.js +++ b/ext/mixed/js/dom.js @@ -63,6 +63,20 @@ class DOM { } } + static getActiveModifiers(event) { + const modifiers = new Set(); + if (event.altKey) { modifiers.add('alt'); } + if (event.ctrlKey) { modifiers.add('ctrl'); } + if (event.metaKey) { modifiers.add('meta'); } + if (event.shiftKey) { modifiers.add('shift'); } + return modifiers; + } + + static getKeyFromEvent(event) { + const key = event.key; + return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : ''); + } + static getFullscreenElement() { return ( document.fullscreenElement || diff --git a/ext/mixed/js/text-scanner.js b/ext/mixed/js/text-scanner.js index 774eef44..d74a04f8 100644 --- a/ext/mixed/js/text-scanner.js +++ b/ext/mixed/js/text-scanner.js @@ -70,6 +70,9 @@ class TextScanner extends EventDispatcher { return; } + const modifiers = DOM.getActiveModifiers(e); + this.trigger('activeModifiersChanged', {modifiers}); + const scanningOptions = this.options.scanning; const scanningModifier = scanningOptions.modifier; if (!( |