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
This commit is contained in:
siikamiika 2020-05-03 04:39:24 +03:00 committed by GitHub
parent acfdaa4f48
commit 77b744e675
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 254 additions and 29 deletions

View File

@ -97,6 +97,7 @@
"parseUrl": "readonly", "parseUrl": "readonly",
"areSetsEqual": "readonly", "areSetsEqual": "readonly",
"getSetIntersection": "readonly", "getSetIntersection": "readonly",
"getSetDifference": "readonly",
"EventDispatcher": "readonly", "EventDispatcher": "readonly",
"EventListenerCollection": "readonly", "EventListenerCollection": "readonly",
"EXTENSION_IS_BROWSER_EDGE": "readonly" "EXTENSION_IS_BROWSER_EDGE": "readonly"

View File

@ -36,6 +36,24 @@ function _profileConditionTestDomainList(url, domainList) {
return false; 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 = { const profileConditionsDescriptor = {
popupLevel: { popupLevel: {
name: 'Popup Level', name: 'Popup Level',
@ -100,5 +118,53 @@ const profileConditionsDescriptor = {
test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url)) 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)
}
}
} }
}; };

View File

@ -17,6 +17,7 @@
/* global /* global
* ClipboardMonitor * ClipboardMonitor
* DOM
* Display * Display
* QueryParser * QueryParser
* apiClipboardGet * apiClipboardGet
@ -178,7 +179,7 @@ class DisplaySearch extends Display {
} }
onKeyDown(e) { onKeyDown(e) {
const key = Display.getKeyFromEvent(e); const key = DOM.getKeyFromEvent(e);
const ignoreKeys = this._onKeyDownIgnoreKeys; const ignoreKeys = this._onKeyDownIgnoreKeys;
const activeModifierMap = new Map([ const activeModifierMap = new Map([

View File

@ -16,6 +16,7 @@
*/ */
/* global /* global
* DOM
* conditionsNormalizeOptionValue * conditionsNormalizeOptionValue
*/ */
@ -177,7 +178,8 @@ ConditionsUI.Condition = class Condition {
this.parent = parent; this.parent = parent;
this.condition = condition; this.condition = condition;
this.container = ConditionsUI.instantiateTemplate('#condition-template').appendTo(parent.container); 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.typeSelect = this.container.find('.condition-type');
this.operatorSelect = this.container.find('.condition-operator'); this.operatorSelect = this.container.find('.condition-operator');
this.removeButton = this.container.find('.condition-remove'); this.removeButton = this.container.find('.condition-remove');
@ -186,14 +188,13 @@ ConditionsUI.Condition = class Condition {
this.updateOperators(); this.updateOperators();
this.updateInput(); this.updateInput();
this.input.on('change', this.onInputChanged.bind(this));
this.typeSelect.on('change', this.onConditionTypeChanged.bind(this)); this.typeSelect.on('change', this.onConditionTypeChanged.bind(this));
this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this)); this.operatorSelect.on('change', this.onConditionOperatorChanged.bind(this));
this.removeButton.on('click', this.onRemoveClicked.bind(this)); this.removeButton.on('click', this.onRemoveClicked.bind(this));
} }
cleanup() { cleanup() {
this.input.off('change'); this.inputInner.off('change');
this.typeSelect.off('change'); this.typeSelect.off('change');
this.operatorSelect.off('change'); this.operatorSelect.off('change');
this.removeButton.off('click'); this.removeButton.off('click');
@ -236,21 +237,48 @@ ConditionsUI.Condition = class Condition {
updateInput() { updateInput() {
const conditionDescriptors = this.parent.parent.conditionDescriptors; const conditionDescriptors = this.parent.parent.conditionDescriptors;
const {type, operator} = this.condition; const {type, operator} = this.condition;
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([ const props = new Map([
['placeholder', ''], ['placeholder', ''],
['type', 'text'] ['type', 'text']
]); ]);
const objects = [];
if (hasOwn(conditionDescriptors, type)) {
const conditionDescriptor = conditionDescriptors[type];
objects.push(conditionDescriptor);
if (hasOwn(conditionDescriptor.operators, operator)) {
const operatorDescriptor = conditionDescriptor.operators[operator];
objects.push(operatorDescriptor);
}
}
for (const object of objects) { for (const object of objects) {
if (hasOwn(object, 'placeholder')) { if (hasOwn(object, 'placeholder')) {
props.set('placeholder', object.placeholder); props.set('placeholder', object.placeholder);
@ -266,12 +294,95 @@ ConditionsUI.Condition = class Condition {
} }
for (const [prop, value] of props.entries()) { for (const [prop, value] of props.entries()) {
this.input.prop(prop, value); inputInner.prop(prop, value);
} }
const {valid} = this.validateValue(this.condition.value); return inputInner;
this.input.toggleClass('is-invalid', !valid); }
this.input.val(this.condition.value);
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) { validateValue(value) {
@ -291,9 +402,9 @@ ConditionsUI.Condition = class Condition {
} }
onInputChanged() { onInputChanged() {
const {valid, value} = this.validateValue(this.input.val()); const {valid, value} = this.validateValue(this.inputInner.val());
this.input.toggleClass('is-invalid', !valid); this.inputInner.toggleClass('is-invalid', !valid);
this.input.val(value); this.inputInner.val(value);
this.condition.value = value; this.condition.value = value;
this.save(); this.save();
} }

View File

@ -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-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="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-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 class="input-group-btn"><button class="btn btn-danger condition-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div>
</div></template> </div></template>
<template id="condition-group-separator-template"><div class="input-group"> <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"> <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> <button class="btn btn-default condition-add"><span class="glyphicon glyphicon-plus"></span></button>
</div></template> </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>
<div> <div>

View File

@ -50,6 +50,9 @@ class Frontend {
); );
this._textScanner.onSearchSource = this.onSearchSource.bind(this); this._textScanner.onSearchSource = this.onSearchSource.bind(this);
this._activeModifiers = new Set();
this._optionsUpdatePending = false;
this._windowMessageHandlers = new Map([ this._windowMessageHandlers = new Map([
['popupClose', () => this._textScanner.clearSelection(false)], ['popupClose', () => this._textScanner.clearSelection(false)],
['selectionCopy', () => document.execCommand('copy')] ['selectionCopy', () => document.execCommand('copy')]
@ -90,6 +93,7 @@ class Frontend {
chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this)); chrome.runtime.onMessage.addListener(this.onRuntimeMessage.bind(this));
this._textScanner.on('clearSelection', this.onClearSelection.bind(this)); this._textScanner.on('clearSelection', this.onClearSelection.bind(this));
this._textScanner.on('activeModifiersChanged', this.onActiveModifiersChanged.bind(this));
this._updateContentScale(); this._updateContentScale();
this._broadcastRootPopupInformation(); this._broadcastRootPopupInformation();
@ -173,12 +177,21 @@ class Frontend {
} }
} }
async updatePendingOptions() {
if (this._optionsUpdatePending) {
this._optionsUpdatePending = false;
await this.updateOptions();
}
}
async setTextSource(textSource) { async setTextSource(textSource) {
await this.onSearchSource(textSource, 'script'); await this.onSearchSource(textSource, 'script');
this._textScanner.setCurrentTextSource(textSource); this._textScanner.setCurrentTextSource(textSource);
} }
async onSearchSource(textSource, cause) { async onSearchSource(textSource, cause) {
await this.updatePendingOptions();
let results = null; let results = null;
try { try {
@ -254,12 +267,24 @@ class Frontend {
onClearSelection({passive}) { onClearSelection({passive}) {
this.popup.hide(!passive); this.popup.hide(!passive);
this.popup.clearAutoPlayTimer(); 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() { async getOptionsContext() {
const url = this._getUrl !== null ? await this._getUrl() : window.location.href; const url = this._getUrl !== null ? await this._getUrl() : window.location.href;
const depth = this.popup.depth; const depth = this.popup.depth;
return {depth, url}; const modifierKeys = [...this._activeModifiers];
return {depth, url, modifierKeys};
} }
_showPopupContent(textSource, optionsContext, type=null, details=null) { _showPopupContent(textSource, optionsContext, type=null, details=null) {

View File

@ -146,6 +146,12 @@ function getSetIntersection(set1, set2) {
return result; return result;
} }
function getSetDifference(set1, set2) {
return new Set(
[...set1].filter((value) => !set2.has(value))
);
}
/* /*
* Async utilities * Async utilities

View File

@ -338,7 +338,7 @@ class Display {
} }
onKeyDown(e) { onKeyDown(e) {
const key = Display.getKeyFromEvent(e); const key = DOM.getKeyFromEvent(e);
const handler = this._onKeyDownHandlers.get(key); const handler = this._onKeyDownHandlers.get(key);
if (typeof handler === 'function') { if (typeof handler === 'function') {
if (handler(e)) { if (handler(e)) {
@ -964,11 +964,6 @@ class Display {
return elementRect.top - documentRect.top; return elementRect.top - documentRect.top;
} }
static getKeyFromEvent(event) {
const key = event.key;
return (typeof key === 'string' ? (key.length === 1 ? key.toUpperCase() : key) : '');
}
async _getNoteContext() { async _getNoteContext() {
const documentTitle = await this.getDocumentTitle(); const documentTitle = await this.getDocumentTitle();
return { return {

View File

@ -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() { static getFullscreenElement() {
return ( return (
document.fullscreenElement || document.fullscreenElement ||

View File

@ -70,6 +70,9 @@ class TextScanner extends EventDispatcher {
return; return;
} }
const modifiers = DOM.getActiveModifiers(e);
this.trigger('activeModifiersChanged', {modifiers});
const scanningOptions = this.options.scanning; const scanningOptions = this.options.scanning;
const scanningModifier = scanningOptions.modifier; const scanningModifier = scanningOptions.modifier;
if (!( if (!(