From d6a3825a383e13b34c03c0b36e393da52bf8cf89 Mon Sep 17 00:00:00 2001 From: siikamiika Date: Sat, 9 May 2020 18:36:00 +0300 Subject: [PATCH] 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 --- ext/bg/background.html | 1 + ext/bg/js/backend.js | 40 ++--- ext/bg/js/profile-conditions.js | 260 ++++++++++++++-------------- ext/bg/js/settings/conditions-ui.js | 6 +- ext/bg/js/settings/main.js | 19 ++ ext/bg/js/settings/profiles.js | 4 +- ext/bg/settings.html | 9 +- ext/mixed/js/environment.js | 114 ++++++++++++ 8 files changed, 288 insertions(+), 165 deletions(-) create mode 100644 ext/mixed/js/environment.js diff --git a/ext/bg/background.html b/ext/bg/background.html index 7cb76ec3..ca35a3c6 100644 --- a/ext/bg/background.html +++ b/ext/bg/background.html @@ -21,6 +21,7 @@ + diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index 9936baf8..557ceb29 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -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'; - } - } } diff --git a/ext/bg/js/profile-conditions.js b/ext/bg/js/profile-conditions.js index 32309c64..97e09f1c 100644 --- a/ext/bg/js/profile-conditions.js +++ b/ext/bg/js/profile-conditions.js @@ -15,6 +15,9 @@ * along with this program. If not, see . */ +/* 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) - } - } - } -}; + }; +})(); diff --git a/ext/bg/js/settings/conditions-ui.js b/ext/bg/js/settings/conditions-ui.js index 0670de5a..031689a7 100644 --- a/ext/bg/js/settings/conditions-ui.js +++ b/ext/bg/js/settings/conditions-ui.js @@ -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(); }; diff --git a/ext/bg/js/settings/main.js b/ext/bg/js/settings/main.js index cf75d629..61395b1c 100644 --- a/ext/bg/js/settings/main.js +++ b/ext/bg/js/settings/main.js @@ -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(); diff --git a/ext/bg/js/settings/profiles.js b/ext/bg/js/settings/profiles.js index 3f4b1da7..bdf5a13d 100644 --- a/ext/bg/js/settings/profiles.js +++ b/ext/bg/js/settings/profiles.js @@ -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); diff --git a/ext/bg/settings.html b/ext/bg/settings.html index a0981687..3ce91f12 100644 --- a/ext/bg/settings.html +++ b/ext/bg/settings.html @@ -412,13 +412,7 @@
- +
@@ -1131,6 +1125,7 @@ + diff --git a/ext/mixed/js/environment.js b/ext/mixed/js/environment.js new file mode 100644 index 00000000..e5bc20a7 --- /dev/null +++ b/ext/mixed/js/environment.js @@ -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 . + */ + + +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}; + } +}