Modifier key platform names (#519)

* wip

* add environment class

* use Environment class

* use Environment for scanning modifier options

* remove Environment in favor of API

* await promise

* use modifier symbols on macOS

* fix key separator issues

* if else to switch

* simplify variable names
This commit is contained in:
siikamiika 2020-05-09 18:36:00 +03:00 committed by GitHub
parent 48cf646973
commit d6a3825a38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 288 additions and 165 deletions

View File

@ -21,6 +21,7 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
<script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/japanese.js"></script>
<script src="/bg/js/anki.js"></script>

View File

@ -24,6 +24,7 @@
* ClipboardMonitor
* Database
* DictionaryImporter
* Environment
* JsonSchema
* Mecab
* ObjectPropertyAccessor
@ -35,6 +36,7 @@
* optionsLoad
* optionsSave
* profileConditionsDescriptor
* profileConditionsDescriptorPromise
* requestJson
* requestText
* utilIsolate
@ -42,6 +44,7 @@
class Backend {
constructor() {
this.environment = new Environment();
this.database = new Database();
this.dictionaryImporter = new DictionaryImporter();
this.translator = new Translator(this.database);
@ -100,7 +103,7 @@ class Backend {
['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}],
['frameInformationGet', {async: true, contentScript: true, handler: this._onApiFrameInformationGet.bind(this)}],
['injectStylesheet', {async: true, contentScript: true, handler: this._onApiInjectStylesheet.bind(this)}],
['getEnvironmentInfo', {async: true, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}],
['getEnvironmentInfo', {async: false, contentScript: true, handler: this._onApiGetEnvironmentInfo.bind(this)}],
['clipboardGet', {async: true, contentScript: true, handler: this._onApiClipboardGet.bind(this)}],
['getDisplayTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetDisplayTemplatesHtml.bind(this)}],
['getQueryParserTemplatesHtml', {async: true, contentScript: true, handler: this._onApiGetQueryParserTemplatesHtml.bind(this)}],
@ -140,9 +143,12 @@ class Backend {
}, 1000);
this._updateBadge();
await this.environment.prepare();
await this.database.prepare();
await this.translator.prepare();
await profileConditionsDescriptorPromise;
this.optionsSchema = await requestJson(chrome.runtime.getURL('/bg/data/options-schema.json'), 'GET');
this.defaultAnkiFieldTemplates = await requestText(chrome.runtime.getURL('/bg/data/default-anki-field-templates.handlebars'), 'GET');
this.options = await optionsLoad();
@ -635,15 +641,8 @@ class Backend {
});
}
async _onApiGetEnvironmentInfo() {
const browser = await Backend._getBrowser();
const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
return {
browser,
platform: {
os: platform.os
}
};
_onApiGetEnvironmentInfo() {
return this.environment.getInfo();
}
async _onApiClipboardGet() {
@ -659,7 +658,7 @@ class Backend {
being an extension with clipboard permissions. It effectively asks for the
non-extension permission for clipboard access.
*/
const browser = await Backend._getBrowser();
const {browser} = this.environment.getInfo();
if (browser === 'firefox' || browser === 'firefox-mobile') {
return await navigator.clipboard.readText();
} else {
@ -1211,23 +1210,4 @@ class Backend {
// Edge throws exception for no reason here.
}
}
static async _getBrowser() {
if (EXTENSION_IS_BROWSER_EDGE) {
return 'edge';
}
if (typeof browser !== 'undefined') {
try {
const info = await browser.runtime.getBrowserInfo();
if (info.name === 'Fennec') {
return 'firefox-mobile';
}
} catch (e) {
// NOP
}
return 'firefox';
} else {
return 'chrome';
}
}
}

View File

@ -15,6 +15,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/* global
* Environment
*/
function _profileConditionTestDomain(urlDomain, domain) {
return (
@ -36,135 +39,140 @@ function _profileConditionTestDomainList(url, domainList) {
return false;
}
const _profileModifierKeys = [
{optionValue: 'alt', name: 'Alt'},
{optionValue: 'ctrl', name: 'Ctrl'},
{optionValue: 'shift', name: 'Shift'}
];
let profileConditionsDescriptor = null;
if (!hasOwn(window, 'netscape')) {
_profileModifierKeys.push({optionValue: 'meta', name: 'Meta'});
}
const profileConditionsDescriptorPromise = (async () => {
const environment = new Environment();
await environment.prepare();
const _profileModifierValueToName = new Map(
_profileModifierKeys.map(({optionValue, name}) => [optionValue, name])
);
const modifiers = environment.getInfo().modifiers;
const modifierSeparator = modifiers.separator;
const modifierKeyValues = modifiers.keys.map(
({value, name}) => ({optionValue: value, name})
);
const _profileModifierNameToValue = new Map(
_profileModifierKeys.map(({optionValue, name}) => [name, optionValue])
);
const modifierValueToName = new Map(
modifierKeyValues.map(({optionValue, name}) => [optionValue, name])
);
const profileConditionsDescriptor = {
popupLevel: {
name: 'Popup Level',
description: 'Use profile depending on the level of the popup.',
placeholder: 'Number',
type: 'number',
step: 1,
defaultValue: 0,
defaultOperator: 'equal',
transform: (optionValue) => parseInt(optionValue, 10),
transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
operators: {
equal: {
name: '=',
test: ({depth}, optionValue) => (depth === optionValue)
},
notEqual: {
name: '\u2260',
test: ({depth}, optionValue) => (depth !== optionValue)
},
lessThan: {
name: '<',
test: ({depth}, optionValue) => (depth < optionValue)
},
greaterThan: {
name: '>',
test: ({depth}, optionValue) => (depth > optionValue)
},
lessThanOrEqual: {
name: '\u2264',
test: ({depth}, optionValue) => (depth <= optionValue)
},
greaterThanOrEqual: {
name: '\u2265',
test: ({depth}, optionValue) => (depth >= optionValue)
const modifierNameToValue = new Map(
modifierKeyValues.map(({optionValue, name}) => [name, optionValue])
);
profileConditionsDescriptor = {
popupLevel: {
name: 'Popup Level',
description: 'Use profile depending on the level of the popup.',
placeholder: 'Number',
type: 'number',
step: 1,
defaultValue: 0,
defaultOperator: 'equal',
transform: (optionValue) => parseInt(optionValue, 10),
transformReverse: (transformedOptionValue) => `${transformedOptionValue}`,
validateTransformed: (transformedOptionValue) => Number.isFinite(transformedOptionValue),
operators: {
equal: {
name: '=',
test: ({depth}, optionValue) => (depth === optionValue)
},
notEqual: {
name: '\u2260',
test: ({depth}, optionValue) => (depth !== optionValue)
},
lessThan: {
name: '<',
test: ({depth}, optionValue) => (depth < optionValue)
},
greaterThan: {
name: '>',
test: ({depth}, optionValue) => (depth > optionValue)
},
lessThanOrEqual: {
name: '\u2264',
test: ({depth}, optionValue) => (depth <= optionValue)
},
greaterThanOrEqual: {
name: '\u2265',
test: ({depth}, optionValue) => (depth >= optionValue)
}
}
},
url: {
name: 'URL',
description: 'Use profile depending on the URL of the current website.',
defaultOperator: 'matchDomain',
operators: {
matchDomain: {
name: 'Matches Domain',
placeholder: 'Comma separated list of domains',
defaultValue: 'example.com',
transformCache: {},
transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
},
matchRegExp: {
name: 'Matches RegExp',
placeholder: 'Regular expression',
defaultValue: 'example\\.com',
transformCache: {},
transform: (optionValue) => new RegExp(optionValue, 'i'),
transformReverse: (transformedOptionValue) => transformedOptionValue.source,
test: ({url}, transformedOptionValue) => (transformedOptionValue !== null && transformedOptionValue.test(url))
}
}
},
modifierKeys: {
name: 'Modifier Keys',
description: 'Use profile depending on the active modifier keys.',
values: modifierKeyValues,
defaultOperator: 'are',
operators: {
are: {
name: 'are',
placeholder: 'Press one or more modifier keys here',
defaultValue: [],
type: 'keyMulti',
keySeparator: modifierSeparator,
transformInput: (optionValue) => optionValue
.split(modifierSeparator)
.filter((v) => v.length > 0)
.map((v) => modifierNameToValue.get(v)),
transformReverse: (transformedOptionValue) => transformedOptionValue
.map((v) => modifierValueToName.get(v))
.join(modifierSeparator),
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',
keySeparator: modifierSeparator,
transformInput: (optionValue) => optionValue
.split(modifierSeparator)
.filter((v) => v.length > 0)
.map((v) => modifierNameToValue.get(v)),
transformReverse: (transformedOptionValue) => transformedOptionValue
.map((v) => modifierValueToName.get(v))
.join(modifierSeparator),
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)
}
}
}
},
url: {
name: 'URL',
description: 'Use profile depending on the URL of the current website.',
defaultOperator: 'matchDomain',
operators: {
matchDomain: {
name: 'Matches Domain',
placeholder: 'Comma separated list of domains',
defaultValue: 'example.com',
transformCache: {},
transform: (optionValue) => optionValue.split(/[,;\s]+/).map((v) => v.trim().toLowerCase()).filter((v) => v.length > 0),
transformReverse: (transformedOptionValue) => transformedOptionValue.join(', '),
validateTransformed: (transformedOptionValue) => (transformedOptionValue.length > 0),
test: ({url}, transformedOptionValue) => _profileConditionTestDomainList(url, transformedOptionValue)
},
matchRegExp: {
name: 'Matches RegExp',
placeholder: 'Regular expression',
defaultValue: 'example\\.com',
transformCache: {},
transform: (optionValue) => new RegExp(optionValue, 'i'),
transformReverse: (transformedOptionValue) => transformedOptionValue.source,
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',
transformInput: (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',
transformInput: (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

@ -310,10 +310,14 @@ ConditionsUI.Condition = class Condition {
inputInner.prop('readonly', true);
let values = [];
let keySeparator = ' + ';
for (const object of objects) {
if (hasOwn(object, 'values')) {
values = object.values;
}
if (hasOwn(object, 'keySeparator')) {
keySeparator = object.keySeparator;
}
}
const pressedKeyIndices = new Set();
@ -347,7 +351,7 @@ ConditionsUI.Condition = class Condition {
}
}
const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(' + ');
const inputValue = [...pressedKeyIndices].map((i) => values[i].name).join(keySeparator);
inputInner.val(inputValue);
inputInner.change();
};

View File

@ -22,6 +22,7 @@
* ankiTemplatesInitialize
* ankiTemplatesUpdateValue
* apiForwardLogsToBackend
* apiGetEnvironmentInfo
* apiOptionsSave
* appearanceInitialize
* audioSettingsInitialize
@ -285,6 +286,23 @@ function showExtensionInformation() {
node.textContent = `${manifest.name} v${manifest.version}`;
}
async function settingsPopulateModifierKeys() {
const scanModifierKeySelect = document.querySelector('#scan-modifier-key');
scanModifierKeySelect.textContent = '';
const environment = await apiGetEnvironmentInfo();
const modifierKeys = [
{value: 'none', name: 'None'},
...environment.modifiers.keys
];
for (const {value, name} of modifierKeys) {
const option = document.createElement('option');
option.value = value;
option.textContent = name;
scanModifierKeySelect.appendChild(option);
}
}
async function onReady() {
apiForwardLogsToBackend();
@ -292,6 +310,7 @@ async function onReady() {
showExtensionInformation();
await settingsPopulateModifierKeys();
formSetupEventListeners();
appearanceInitialize();
await audioSettingsInitialize();

View File

@ -23,6 +23,7 @@
* getOptionsFullMutable
* getOptionsMutable
* profileConditionsDescriptor
* profileConditionsDescriptorPromise
* settingsSaveOptions
* utilBackgroundIsolate
*/
@ -98,6 +99,7 @@ async function profileFormWrite(optionsFull) {
profileConditionsContainer.cleanup();
}
await profileConditionsDescriptorPromise;
profileConditionsContainer = new ConditionsUI.Container(
profileConditionsDescriptor,
'popupLevel',
@ -128,7 +130,7 @@ function profileOptionsPopulateSelect(select, profiles, currentValue, ignoreIndi
}
async function profileOptionsUpdateTarget(optionsFull) {
profileFormWrite(optionsFull);
await profileFormWrite(optionsFull);
const optionsContext = getOptionsContext();
const options = await getOptionsMutable(optionsContext);

View File

@ -412,13 +412,7 @@
<div class="form-group">
<label for="scan-modifier-key">Scan modifier key</label>
<select class="form-control" id="scan-modifier-key">
<option value="none">None</option>
<option value="alt">Alt</option>
<option value="ctrl">Ctrl</option>
<option value="shift">Shift</option>
<option data-hide-for-browser="firefox firefox-mobile" value="meta">Meta</option>
</select>
<select class="form-control" id="scan-modifier-key"></select>
</div>
</div>
@ -1131,6 +1125,7 @@
<script src="/mixed/js/core.js"></script>
<script src="/mixed/js/dom.js"></script>
<script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script>

114
ext/mixed/js/environment.js Normal file
View File

@ -0,0 +1,114 @@
/*
* Copyright (C) 2020 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 <https://www.gnu.org/licenses/>.
*/
class Environment {
constructor() {
this._cachedEnvironmentInfo = null;
}
async prepare() {
this._cachedEnvironmentInfo = await this._loadEnvironmentInfo();
}
getInfo() {
if (this._cachedEnvironmentInfo === null) { throw new Error('Not prepared'); }
return this._cachedEnvironmentInfo;
}
async _loadEnvironmentInfo() {
const browser = await this._getBrowser();
const platform = await new Promise((resolve) => chrome.runtime.getPlatformInfo(resolve));
const modifierInfo = this._getModifierInfo(browser, platform.os);
return {
browser,
platform: {
os: platform.os
},
modifiers: modifierInfo
};
}
async _getBrowser() {
if (EXTENSION_IS_BROWSER_EDGE) {
return 'edge';
}
if (typeof browser !== 'undefined') {
try {
const info = await browser.runtime.getBrowserInfo();
if (info.name === 'Fennec') {
return 'firefox-mobile';
}
} catch (e) {
// NOP
}
return 'firefox';
} else {
return 'chrome';
}
}
_getModifierInfo(browser, os) {
let osKeys;
let separator;
switch (os) {
case 'win':
separator = ' + ';
osKeys = [
['alt', 'Alt'],
['ctrl', 'Ctrl'],
['shift', 'Shift'],
['meta', 'Windows']
];
break;
case 'mac':
separator = '';
osKeys = [
['alt', '⌥'],
['ctrl', '⌃'],
['shift', '⇧'],
['meta', '⌘']
];
break;
case 'linux':
case 'openbsd':
case 'cros':
case 'android':
separator = ' + ';
osKeys = [
['alt', 'Alt'],
['ctrl', 'Ctrl'],
['shift', 'Shift'],
['meta', 'Super']
];
break;
default:
throw new Error(`Invalid OS: ${os}`);
}
const isFirefox = (browser === 'firefox' || browser === 'firefox-mobile');
const keys = [];
for (const [value, name] of osKeys) {
// Firefox doesn't support event.metaKey on platforms other than macOS
if (value === 'meta' && isFirefox && os !== 'mac') { continue; }
keys.push({value, name});
}
return {keys, separator};
}
}