From 4e94695c758215a950fe53911e1c1e30e47b9c98 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 4 Nov 2017 23:55:09 +0900 Subject: [PATCH 1/3] add key utils --- src/shared/utils/keys.js | 78 +++++++++++++++ test/shared/utils/keys.test.js | 133 +++++++++++++++++++++++++ test/shared/{util => utils}/re.test.js | 0 3 files changed, 211 insertions(+) create mode 100644 src/shared/utils/keys.js create mode 100644 test/shared/utils/keys.test.js rename test/shared/{util => utils}/re.test.js (100%) diff --git a/src/shared/utils/keys.js b/src/shared/utils/keys.js new file mode 100644 index 0000000..dfdb954 --- /dev/null +++ b/src/shared/utils/keys.js @@ -0,0 +1,78 @@ +const modifierdKeyName = (name) => { + if (name.length === 1) { + return name; + } else if (name === 'Escape') { + return 'Esc'; + } + return name; +}; + +const fromKeyboardEvent = (e) => { + return { + key: modifierdKeyName(e.key), + shiftKey: e.shiftKey, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + metaKey: e.metaKey, + }; +}; + +const fromMapKey = (key) => { + if (key.startsWith('<') && key.endsWith('>')) { + let inner = key.slice(1, -1); + let shift = inner.includes('S-'); + let base = inner.slice(inner.lastIndexOf('-') + 1); + if (shift && base.length === 1) { + base = base.toUpperCase(); + } else if (!shift && base.length === 1) { + base = base.toLowerCase(); + } + return { + key: base, + shiftKey: inner.includes('S-'), + ctrlKey: inner.includes('C-'), + altKey: inner.includes('A-'), + metaKey: inner.includes('M-'), + }; + } + return { + key: key, + shiftKey: key.toLowerCase() !== key, + ctrlKey: false, + altKey: false, + metaKey: false, + }; +}; + +const fromMapKeys = (keys) => { + const fromMapKeysRecursive = (remainings, mappedKeys) => { + if (remainings.length === 0) { + return mappedKeys; + } + + let nextPos = 1; + if (remainings.startsWith('<')) { + let ltPos = remainings.indexOf('>'); + if (ltPos > 0) { + nextPos = ltPos + 1; + } + } + + return fromMapKeysRecursive( + remainings.slice(nextPos), + mappedKeys.concat([fromMapKey(remainings.slice(0, nextPos))]) + ); + }; + + return fromMapKeysRecursive(keys, []); +}; + +const equals = (e1, e2) => { + return e1.key === e2.key && + e1.ctrlKey === e2.ctrlKey && + e1.metaKey === e2.metaKey && + e1.altKey === e2.altKey && + e1.shiftKey === e2.shiftKey; +}; + +export { fromKeyboardEvent, fromMapKey, fromMapKeys, equals }; diff --git a/test/shared/utils/keys.test.js b/test/shared/utils/keys.test.js new file mode 100644 index 0000000..77e2b12 --- /dev/null +++ b/test/shared/utils/keys.test.js @@ -0,0 +1,133 @@ +import { expect } from 'chai'; +import * as keys from 'shared/utils/keys'; + +describe("keys util", () => { + describe('fromKeyboardEvent', () => { + it('returns from keyboard input Ctrl+X', () => { + let k = keys.fromKeyboardEvent({ + key: 'x', shiftKey: false, ctrlKey: true, altKey: false, metaKey: true + }); + expect(k.key).to.equal('x'); + expect(k.shiftKey).to.be.false; + expect(k.ctrlKey).to.be.true; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.true; + }); + + it('returns from keyboard input Shift+Esc', () => { + let k = keys.fromKeyboardEvent({ + key: 'Escape', shiftKey: true, ctrlKey: false, altKey: false, metaKey: true + }); + expect(k.key).to.equal('Esc'); + expect(k.shiftKey).to.be.true; + expect(k.ctrlKey).to.be.false; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.true; + }); + }); + + describe('fromMapKey', () => { + it('return for X', () => { + let key = keys.fromMapKey('x'); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('return for Shift+X', () => { + let key = keys.fromMapKey('X'); + expect(key.key).to.equal('X'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('return for Ctrl+X', () => { + let key = keys.fromMapKey(''); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Ctrl+Meta+X', () => { + let key = keys.fromMapKey(''); + expect(key.key).to.equal('x'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.true; + }); + + it('returns for Ctrl+Shift+x', () => { + let key = keys.fromMapKey(''); + expect(key.key).to.equal('X'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Shift+Esc', () => { + let key = keys.fromMapKey(''); + expect(key.key).to.equal('Esc'); + expect(key.shiftKey).to.be.true; + expect(key.ctrlKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + + it('returns for Ctrl+Esc', () => { + let key = keys.fromMapKey(''); + expect(key.key).to.equal('Esc'); + expect(key.shiftKey).to.be.false; + expect(key.ctrlKey).to.be.true; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + }); + }); + + describe('fromMapKeys', () => { + it('returns mapped keys for Shift+Esc', () => { + let keyArray = keys.fromMapKeys(''); + expect(keyArray).to.have.lengthOf(1); + expect(keyArray[0].key).to.equal('Esc'); + expect(keyArray[0].shiftKey).to.be.true; + }); + + it('returns mapped keys for ad', () => { + let keyArray = keys.fromMapKeys('ad'); + expect(keyArray).to.have.lengthOf(5); + expect(keyArray[0].key).to.equal('a'); + expect(keyArray[1].ctrlKey).to.be.true; + expect(keyArray[1].key).to.equal('b'); + expect(keyArray[2].altKey).to.be.true; + expect(keyArray[2].key).to.equal('c'); + expect(keyArray[3].key).to.equal('d'); + expect(keyArray[4].metaKey).to.be.true; + expect(keyArray[4].key).to.equal('e'); + }); + }) + + describe('equals', () => { + expect(keys.equals({ + key: 'x', + ctrlKey: true, + }, { + key: 'x', + ctrlKey: true, + })).to.be.true; + + expect(keys.equals({ + key: 'X', + shiftKey: true, + }, { + key: 'x', + ctrlKey: true, + })).to.be.false; + }); +}); diff --git a/test/shared/util/re.test.js b/test/shared/utils/re.test.js similarity index 100% rename from test/shared/util/re.test.js rename to test/shared/utils/re.test.js From 036ede3379285cbe678d79aad3b9442dca8b31e6 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 5 Nov 2017 09:47:30 +0900 Subject: [PATCH 2/3] support mutliple modifiers for key bindings --- src/content/actions/setting.js | 16 +++++++- src/content/components/common/follow.js | 2 +- src/content/components/common/input.js | 21 +--------- src/content/components/common/keymapper.js | 23 ++++++++--- .../top-content/follow-controller.js | 2 +- src/content/reducers/input.js | 6 +-- src/content/reducers/setting.js | 2 +- test/content/actions/setting.test.js | 23 ++++++++++- test/content/components/common/input.test.js | 41 +++---------------- test/content/reducers/input.test.js | 12 +++--- test/content/reducers/setting.test.js | 2 +- 11 files changed, 75 insertions(+), 75 deletions(-) diff --git a/src/content/actions/setting.js b/src/content/actions/setting.js index c874294..353dd24 100644 --- a/src/content/actions/setting.js +++ b/src/content/actions/setting.js @@ -1,9 +1,23 @@ import actions from 'content/actions'; +import * as keyUtils from 'shared/utils/keys'; const set = (value) => { + let maps = new Map(); + if (value.keymaps) { + let entries = Object.entries(value.keymaps).map((entry) => { + return [ + keyUtils.fromMapKeys(entry[0]), + entry[1], + ]; + }); + maps = new Map(entries); + } + return { type: actions.SETTING_SET, - value, + value: Object.assign({}, value, { + keymaps: maps, + }) }; }; diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js index 83aeb0a..7a35105 100644 --- a/src/content/components/common/follow.js +++ b/src/content/components/common/follow.js @@ -46,7 +46,7 @@ export default class Follow { } this.win.parent.postMessage(JSON.stringify({ type: messages.FOLLOW_KEY_PRESS, - key, + key: key.key, }), '*'); return true; } diff --git a/src/content/components/common/input.js b/src/content/components/common/input.js index 8b1d35d..22b0a91 100644 --- a/src/content/components/common/input.js +++ b/src/content/components/common/input.js @@ -1,22 +1,5 @@ import * as dom from 'shared/utils/dom'; - -const modifierdKeyName = (name) => { - if (name.length === 1) { - return name.toUpperCase(); - } else if (name === 'Escape') { - return 'Esc'; - } - return name; -}; - -const mapKey = (e) => { - if (e.ctrlKey) { - return ''; - } else if (e.shiftKey && e.key.length !== 1) { - return ''; - } - return e.key; -}; +import * as keys from 'shared/utils/keys'; export default class InputComponent { constructor(target) { @@ -64,7 +47,7 @@ export default class InputComponent { return; } - let key = mapKey(e); + let key = keys.fromKeyboardEvent(e); for (let listener of this.onKeyListeners) { let stop = listener(key); diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js index 1da3c0d..0abbc91 100644 --- a/src/content/components/common/keymapper.js +++ b/src/content/components/common/keymapper.js @@ -1,6 +1,19 @@ import * as inputActions from 'content/actions/input'; import * as operationActions from 'content/actions/operation'; import operations from 'shared/operations'; +import * as keyUtils from 'shared/utils/keys'; + +const mapStartsWith = (mapping, keys) => { + if (mapping.length < keys.length) { + return false; + } + for (let i = 0; i < keys.length; ++i) { + if (!keyUtils.equals(mapping[i], keys[i])) { + return false; + } + } + return true; +}; export default class KeymapperComponent { constructor(store) { @@ -14,14 +27,14 @@ export default class KeymapperComponent { let input = state.input; let keymaps = state.setting.keymaps; - let matched = Object.keys(keymaps).filter((keyStr) => { - return keyStr.startsWith(input.keys); + let matched = Array.from(keymaps.keys()).filter((mapping) => { + return mapStartsWith(mapping, input.keys); }); if (!state.addon.enabled) { // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if // the addon disabled matched = matched.filter((keys) => { - let type = keymaps[keys].type; + let type = keymaps.get(keys).type; return type === operations.ADDON_ENABLE || type === operations.ADDON_TOGGLE_ENABLED; }); @@ -30,10 +43,10 @@ export default class KeymapperComponent { this.store.dispatch(inputActions.clearKeys()); return false; } else if (matched.length > 1 || - matched.length === 1 && input.keys !== matched[0]) { + matched.length === 1 && input.keys.length < matched[0].length) { return true; } - let operation = keymaps[matched]; + let operation = keymaps.get(matched[0]); this.store.dispatch(operationActions.exec(operation)); this.store.dispatch(inputActions.clearKeys()); return true; diff --git a/src/content/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js index 38869e6..d373177 100644 --- a/src/content/components/top-content/follow-controller.js +++ b/src/content/components/top-content/follow-controller.js @@ -76,7 +76,7 @@ export default class FollowController { this.activate(); this.store.dispatch(followControllerActions.disable()); break; - case 'Escape': + case 'Esc': this.store.dispatch(followControllerActions.disable()); break; case 'Backspace': diff --git a/src/content/reducers/input.js b/src/content/reducers/input.js index 9457604..134aa95 100644 --- a/src/content/reducers/input.js +++ b/src/content/reducers/input.js @@ -1,18 +1,18 @@ import actions from 'content/actions'; const defaultState = { - keys: '' + keys: [] }; export default function reducer(state = defaultState, action = {}) { switch (action.type) { case actions.INPUT_KEY_PRESS: return Object.assign({}, state, { - keys: state.keys + action.key + keys: state.keys.concat([action.key]), }); case actions.INPUT_CLEAR_KEYS: return Object.assign({}, state, { - keys: '', + keys: [], }); default: return state; diff --git a/src/content/reducers/setting.js b/src/content/reducers/setting.js index b6f6c58..a54f5a3 100644 --- a/src/content/reducers/setting.js +++ b/src/content/reducers/setting.js @@ -1,7 +1,7 @@ import actions from 'content/actions'; const defaultState = { - keymaps: {}, + keymaps: new Map(), }; export default function reducer(state = defaultState, action = {}) { diff --git a/test/content/actions/setting.test.js b/test/content/actions/setting.test.js index 8855f04..0228fea 100644 --- a/test/content/actions/setting.test.js +++ b/test/content/actions/setting.test.js @@ -7,7 +7,28 @@ describe("setting actions", () => { it('create SETTING_SET action', () => { let action = settingActions.set({ red: 'apple', yellow: 'banana' }); expect(action.type).to.equal(actions.SETTING_SET); - expect(action.value).to.deep.equal({ red: 'apple', yellow: 'banana' }); + expect(action.value.red).to.equal('apple'); + expect(action.value.yellow).to.equal('banana'); + expect(action.value.keymaps).to.be.empty; + }); + + it('converts keymaps', () => { + let action = settingActions.set({ + keymaps: { + 'dd': 'remove current tab', + 'z': 'increment', + } + }); + let keymaps = action.value.keymaps; + + expect(action.value.keymaps).to.have.deep.all.keys( + [ + [{ key: 'd', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, + { key: 'd', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }], + [{ key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, + { key: 'a', shiftKey: false, ctrlKey: true, altKey: false, metaKey: false }], + ] + ); }); }); }); diff --git a/test/content/components/common/input.test.js b/test/content/components/common/input.test.js index 912ac34..a346cf6 100644 --- a/test/content/components/common/input.test.js +++ b/test/content/components/common/input.test.js @@ -4,20 +4,21 @@ import { expect } from "chai"; describe('InputComponent', () => { it('register callbacks', () => { let component = new InputComponent(window.document); + let key = { key: 'a', ctrlKey: true, shiftKey: false, altKey: false, metaKey: false }; component.onKey((key) => { - expect(key).is.equals('a'); + expect(key).to.deep.equal(key); }); - component.onKeyDown({ key: 'a' }); + component.onKeyDown(key); }); it('invoke callback once', () => { let component = new InputComponent(window.document); let a = 0, b = 0; component.onKey((key) => { - if (key == 'a') { + if (key.key == 'a') { ++a; } else { - key == 'b' + key.key == 'b' ++b; } }); @@ -32,38 +33,6 @@ describe('InputComponent', () => { expect(b).is.equals(1); }) - it('add prefix when ctrl pressed', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals(''); - }); - component.onKeyDown({ key: 'a', ctrlKey: true }); - }) - - it('press X', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals('X'); - }); - component.onKeyDown({ key: 'X', shiftKey: true }); - }) - - it('press + ', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals(''); - }); - component.onKeyDown({ key: 'Escape', shiftKey: true }); - }) - - it('press + ', () => { - let component = new InputComponent(window.document); - component.onKey((key) => { - expect(key).is.equals(''); - }); - component.onKeyDown({ key: 'Escape', ctrlKey: true }); - }) - it('does not invoke only meta keys', () => { let component = new InputComponent(window.document); component.onKey((key) => { diff --git a/test/content/reducers/input.test.js b/test/content/reducers/input.test.js index d5e5f6b..d0b5655 100644 --- a/test/content/reducers/input.test.js +++ b/test/content/reducers/input.test.js @@ -5,22 +5,22 @@ import inputReducer from 'content/reducers/input'; describe("input reducer", () => { it('return the initial state', () => { let state = inputReducer(undefined, {}); - expect(state).to.have.deep.property('keys', ''); + expect(state).to.have.deep.property('keys', []); }); it('return next state for INPUT_KEY_PRESS', () => { let action = { type: actions.INPUT_KEY_PRESS, key: 'a' }; let state = inputReducer(undefined, action); - expect(state).to.have.deep.property('keys', 'a'); + expect(state).to.have.deep.property('keys', ['a']); - action = { type: actions.INPUT_KEY_PRESS, key: '' }; + action = { type: actions.INPUT_KEY_PRESS, key: 'b' }; state = inputReducer(state, action); - expect(state).to.have.deep.property('keys', 'a'); + expect(state).to.have.deep.property('keys', ['a', 'b']); }); it('return next state for INPUT_CLEAR_KEYS', () => { let action = { type: actions.INPUT_CLEAR_KEYS }; - let state = inputReducer({ keys: 'abc' }, action); - expect(state).to.have.deep.property('keys', ''); + let state = inputReducer({ keys: [1, 2, 3] }, action); + expect(state).to.have.deep.property('keys', []); }); }); diff --git a/test/content/reducers/setting.test.js b/test/content/reducers/setting.test.js index ef49594..634b299 100644 --- a/test/content/reducers/setting.test.js +++ b/test/content/reducers/setting.test.js @@ -5,7 +5,7 @@ import settingReducer from 'content/reducers/setting'; describe("content setting reducer", () => { it('return the initial state', () => { let state = settingReducer(undefined, {}); - expect(state).to.deep.equal({ keymaps: {} }); + expect(state.keymaps).to.be.empty; }); it('return next state for SETTING_SET', () => { From ccf3c7b421e804172827dd34a995290afc85af10 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 12 Nov 2017 18:21:28 +0900 Subject: [PATCH 3/3] fix for symbol keys --- src/shared/utils/keys.js | 10 +++++++++- test/shared/utils/keys.test.js | 12 ++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/shared/utils/keys.js b/src/shared/utils/keys.js index dfdb954..fba8ce3 100644 --- a/src/shared/utils/keys.js +++ b/src/shared/utils/keys.js @@ -8,9 +8,17 @@ const modifierdKeyName = (name) => { }; const fromKeyboardEvent = (e) => { + let key = modifierdKeyName(e.key); + let shift = e.shiftKey; + if (key.length === 1 && key.toUpperCase() === key.toLowerCase()) { + // make shift false for symbols to enable key bindings by symbold keys. + // But this limits key bindings by symbol keys with Shift (such as Shift+$>. + shift = false; + } + return { key: modifierdKeyName(e.key), - shiftKey: e.shiftKey, + shiftKey: shift, ctrlKey: e.ctrlKey, altKey: e.altKey, metaKey: e.metaKey, diff --git a/test/shared/utils/keys.test.js b/test/shared/utils/keys.test.js index 77e2b12..5ca8b54 100644 --- a/test/shared/utils/keys.test.js +++ b/test/shared/utils/keys.test.js @@ -24,6 +24,18 @@ describe("keys util", () => { expect(k.altKey).to.be.false; expect(k.metaKey).to.be.true; }); + + it('returns from keyboard input Ctrl+$', () => { + // $ required shift pressing on most keyboards + let k = keys.fromKeyboardEvent({ + key: '$', shiftKey: true, ctrlKey: true, altKey: false, metaKey: false + }); + expect(k.key).to.equal('$'); + expect(k.shiftKey).to.be.false; + expect(k.ctrlKey).to.be.true; + expect(k.altKey).to.be.false; + expect(k.metaKey).to.be.false; + }); }); describe('fromMapKey', () => {