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",
"areSetsEqual": "readonly",
"getSetIntersection": "readonly",
"getSetDifference": "readonly",
"EventDispatcher": "readonly",
"EventListenerCollection": "readonly",
"EXTENSION_IS_BROWSER_EDGE": "readonly"

View File

@ -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)
}
}
}
};

View File

@ -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([

View File

@ -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 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']
]);
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) {
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();
}

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-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>

View File

@ -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) {

View File

@ -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

View File

@ -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 {

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

View File

@ -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 (!(