diff --git a/src/actions/operation.js b/src/actions/operation.js index 295fd4f..0bb8310 100644 --- a/src/actions/operation.js +++ b/src/actions/operation.js @@ -2,6 +2,9 @@ import operations from 'shared/operations'; import messages from 'content/messages'; import * as tabs from 'background/tabs'; import * as zooms from 'background/zooms'; +import * as scrolls from 'content/scrolls'; +import * as navigates from 'content/navigates'; +import * as followActions from 'actions/follow'; const sendConsoleShowCommand = (tab, command) => { return browser.tabs.sendMessage(tab.id, { @@ -10,7 +13,43 @@ const sendConsoleShowCommand = (tab, command) => { }); }; -const exec = (operation, tab) => { +const exec = (operation) => { + switch (operation.type) { + case operations.SCROLL_LINES: + return scrolls.scrollLines(window, operation.count); + case operations.SCROLL_PAGES: + return scrolls.scrollPages(window, operation.count); + case operations.SCROLL_TOP: + return scrolls.scrollTop(window); + case operations.SCROLL_BOTTOM: + return scrolls.scrollBottom(window); + case operations.SCROLL_HOME: + return scrolls.scrollLeft(window); + case operations.SCROLL_END: + return scrolls.scrollRight(window); + case operations.FOLLOW_START: + return followActions.enable(false); + case operations.NAVIGATE_HISTORY_PREV: + return navigates.historyPrev(window); + case operations.NAVIGATE_HISTORY_NEXT: + return navigates.historyNext(window); + case operations.NAVIGATE_LINK_PREV: + return navigates.linkPrev(window); + case operations.NAVIGATE_LINK_NEXT: + return navigates.linkNext(window); + case operations.NAVIGATE_PARENT: + return navigates.parent(window); + case operations.NAVIGATE_ROOT: + return navigates.root(window); + default: + browser.runtime.sendMessage({ + type: messages.BACKGROUND_OPERATION, + operation, + }); + } +}; + +const execBackground = (operation, tab) => { switch (operation.type) { case operations.TAB_CLOSE: return tabs.closeTab(tab.id); @@ -45,11 +84,8 @@ const exec = (operation, tab) => { case operations.COMMAND_SHOW_BUFFER: return sendConsoleShowCommand(tab, 'buffer '); default: - return browser.tabs.sendMessage(tab.id, { - type: messages.CONTENT_OPERATION, - operation - }); + return Promise.resolve(); } }; -export { exec }; +export { exec, execBackground }; diff --git a/src/background/index.js b/src/background/index.js index b966c13..dbc10fb 100644 --- a/src/background/index.js +++ b/src/background/index.js @@ -1,7 +1,6 @@ import * as settingsActions from 'actions/setting'; import messages from 'content/messages'; import BackgroundComponent from 'components/background'; -import BackgroundInputComponent from 'components/background-input'; import reducers from 'reducers'; import { createStore } from 'store'; @@ -15,10 +14,8 @@ const store = createStore(reducers, (e, sender) => { } }); const backgroundComponent = new BackgroundComponent(store); -const backgroundInputComponent = new BackgroundInputComponent(store); store.subscribe((sender) => { backgroundComponent.update(sender); - backgroundInputComponent.update(sender); }); store.dispatch(settingsActions.load()); diff --git a/src/components/background-input.js b/src/components/background-input.js deleted file mode 100644 index bd6ecf9..0000000 --- a/src/components/background-input.js +++ /dev/null @@ -1,53 +0,0 @@ -import * as inputActions from 'actions/input'; -import * as operationActions from 'actions/operation'; - -export default class BackgroundInputComponent { - constructor(store) { - this.store = store; - this.keymaps = {}; - this.prevInputs = []; - } - - update(sender) { - let state = this.store.getState(); - this.reloadSettings(state.setting); - this.handleKeyInputs(sender, state.input); - } - - reloadSettings(setting) { - if (!setting.settings.json) { - return; - } - this.keymaps = JSON.parse(setting.settings.json).keymaps; - } - - handleKeyInputs(sender, input) { - if (JSON.stringify(this.prevInputs) === JSON.stringify(input)) { - return; - } - this.prevInputs = input; - - if (input.keys.length === 0) { - return; - } - if (sender) { - return this.handleKeysChanged(sender, input); - } - } - - handleKeysChanged(sender, input) { - let matched = Object.keys(this.keymaps).filter((keyStr) => { - return keyStr.startsWith(input.keys); - }); - if (matched.length === 0) { - this.store.dispatch(inputActions.clearKeys(), sender); - return Promise.resolve(); - } else if (matched.length > 1 || - matched.length === 1 && input.keys !== matched[0]) { - return Promise.resolve(); - } - let operation = this.keymaps[matched]; - this.store.dispatch(operationActions.exec(operation, sender.tab), sender); - this.store.dispatch(inputActions.clearKeys(), sender); - } -} diff --git a/src/components/background.js b/src/components/background.js index 487e3af..4961d85 100644 --- a/src/components/background.js +++ b/src/components/background.js @@ -1,5 +1,5 @@ import messages from 'content/messages'; -import * as inputActions from 'actions/input'; +import * as operationActions from 'actions/operation'; import * as settingsActions from 'actions/setting'; import * as tabActions from 'actions/tab'; import * as commands from 'shared/commands'; @@ -35,9 +35,10 @@ export default class BackgroundComponent { onMessage(message, sender) { switch (message.type) { - case messages.KEYDOWN: + case messages.BACKGROUND_OPERATION: return this.store.dispatch( - inputActions.keyPress(message.key, message.ctrl), sender); + operationActions.execBackground(message.operation, sender.tab), + sender); case messages.OPEN_URL: if (message.newTab) { return this.store.dispatch( @@ -56,10 +57,23 @@ export default class BackgroundComponent { text: e.message, }); }); + case messages.SETTINGS_QUERY: + return Promise.resolve(this.store.getState().setting.settings); case messages.CONSOLE_QUERY_COMPLETIONS: return commands.complete(message.text, this.settings); case messages.SETTINGS_RELOAD: this.store.dispatch(settingsActions.load()); + return this.broadcastSettingsChanged(); } } + + broadcastSettingsChanged() { + return browser.tabs.query({}).then((tabs) => { + for (let tab of tabs) { + browser.tabs.sendMessage(tab.id, { + type: messages.SETTINGS_CHANGED, + }); + } + }); + } } diff --git a/src/components/content-input.js b/src/components/content-input.js index 38d57fd..9568caf 100644 --- a/src/components/content-input.js +++ b/src/components/content-input.js @@ -1,14 +1,20 @@ -import messages from 'content/messages'; - export default class ContentInputComponent { constructor(target) { this.pressed = {}; + this.onKeyListeners = []; target.addEventListener('keypress', this.onKeyPress.bind(this)); target.addEventListener('keydown', this.onKeyDown.bind(this)); target.addEventListener('keyup', this.onKeyUp.bind(this)); } + update() { + } + + onKey(cb) { + this.onKeyListeners.push(cb); + } + onKeyPress(e) { if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') { return; @@ -30,18 +36,32 @@ export default class ContentInputComponent { } capture(e) { - if (e.target instanceof HTMLInputElement || - e.target instanceof HTMLTextAreaElement || - e.target instanceof HTMLSelectElement) { + if (this.fromInput(e)) { if (e.key === 'Escape' && e.target.blur) { e.target.blur(); } return; } - browser.runtime.sendMessage({ - type: messages.KEYDOWN, - key: e.key, - ctrl: e.ctrlKey - }); + if (e.key === 'OS') { + return; + } + + let stop = false; + for (let listener of this.onKeyListeners) { + stop = stop || listener(e.key, e.ctrlKey); + if (stop) { + break; + } + } + if (stop) { + e.preventDefault(); + e.stopPropagation(); + } + } + + fromInput(e) { + return e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement || + e.target instanceof HTMLSelectElement; } } diff --git a/src/components/follow.js b/src/components/follow.js index 9221759..eedbd4d 100644 --- a/src/components/follow.js +++ b/src/components/follow.js @@ -5,21 +5,6 @@ import HintKeyProducer from 'content/hint-key-producer'; const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; -const availableKey = (keyCode) => { - return ( - KeyboardEvent.DOM_VK_0 <= keyCode && keyCode <= KeyboardEvent.DOM_VK_9 || - KeyboardEvent.DOM_VK_A <= keyCode && keyCode <= KeyboardEvent.DOM_VK_Z - ); -}; - -const isNumericKey = (code) => { - return KeyboardEvent.DOM_VK_0 <= code && code <= KeyboardEvent.DOM_VK_9; -}; - -const isAlphabeticKey = (code) => { - return KeyboardEvent.DOM_VK_A <= code && code <= KeyboardEvent.DOM_VK_Z; -}; - const inWindow = (window, element) => { let { top, left, bottom, right @@ -37,9 +22,6 @@ export default class FollowComponent { this.store = store; this.hintElements = {}; this.state = {}; - - let doc = wrapper.ownerDocument; - doc.addEventListener('keydown', this.onKeyDown.bind(this)); } update() { @@ -49,56 +31,50 @@ export default class FollowComponent { this.create(); } else if (prevState.enabled && !this.state.enabled) { this.remove(); - } else if (JSON.stringify(prevState.keys) !== - JSON.stringify(this.state.keys)) { + } else if (prevState.keys !== this.state.keys) { this.updateHints(); } } - onKeyDown(e) { + key(key) { if (!this.state.enabled) { - return; + return false; } - let { keyCode } = e; - switch (keyCode) { - case KeyboardEvent.DOM_VK_ENTER: - case KeyboardEvent.DOM_VK_RETURN: - this.activate(this.hintElements[ - FollowComponent.codeChars(this.state.keys)].target); + switch (key) { + case 'Enter': + this.activate(this.hintElements[this.state.keys].target); return; - case KeyboardEvent.DOM_VK_ESCAPE: + case 'Escape': this.store.dispatch(followActions.disable()); return; - case KeyboardEvent.DOM_VK_BACK_SPACE: - case KeyboardEvent.DOM_VK_DELETE: + case 'Backspace': + case 'Delete': this.store.dispatch(followActions.backspace()); break; default: - if (availableKey(keyCode)) { - this.store.dispatch(followActions.keyPress(keyCode)); + if (DEFAULT_HINT_CHARSET.includes(key)) { + this.store.dispatch(followActions.keyPress(key)); } break; } - - e.stopPropagation(); - e.preventDefault(); + return true; } updateHints() { - let chars = FollowComponent.codeChars(this.state.keys); + let keys = this.state.keys; let shown = Object.keys(this.hintElements).filter((key) => { - return key.startsWith(chars); + return key.startsWith(keys); }); let hidden = Object.keys(this.hintElements).filter((key) => { - return !key.startsWith(chars); + return !key.startsWith(keys); }); if (shown.length === 0) { this.remove(); return; } else if (shown.length === 1) { - this.activate(this.hintElements[chars].target); - this.remove(); + this.activate(this.hintElements[keys].target); + this.store.dispatch(followActions.disable()); } shown.forEach((key) => { @@ -177,24 +153,6 @@ export default class FollowComponent { }); } - static codeChars(codes) { - const CHARCODE_ZERO = '0'.charCodeAt(0); - const CHARCODE_A = 'a'.charCodeAt(0); - - let chars = ''; - - for (let code of codes) { - if (isNumericKey(code)) { - chars += String.fromCharCode( - code - KeyboardEvent.DOM_VK_0 + CHARCODE_ZERO); - } else if (isAlphabeticKey(code)) { - chars += String.fromCharCode( - code - KeyboardEvent.DOM_VK_A + CHARCODE_A); - } - } - return chars; - } - static getTargetElements(doc) { let all = doc.querySelectorAll('a,button,input,textarea'); let filtered = Array.prototype.filter.call(all, (element) => { diff --git a/src/components/keymapper.js b/src/components/keymapper.js new file mode 100644 index 0000000..3685a4f --- /dev/null +++ b/src/components/keymapper.js @@ -0,0 +1,43 @@ +import * as inputActions from 'actions/input'; +import * as operationActions from 'actions/operation'; + +export default class KeymapperComponent { + constructor(store) { + this.store = store; + } + + update() { + } + + key(key, ctrl) { + let keymaps = this.keymaps(); + if (!keymaps) { + return; + } + this.store.dispatch(inputActions.keyPress(key, ctrl)); + + let input = this.store.getState().input; + let matched = Object.keys(keymaps).filter((keyStr) => { + return keyStr.startsWith(input.keys); + }); + if (matched.length === 0) { + this.store.dispatch(inputActions.clearKeys()); + return false; + } else if (matched.length > 1 || + matched.length === 1 && input.keys !== matched[0]) { + return true; + } + let operation = keymaps[matched]; + this.store.dispatch(operationActions.exec(operation)); + this.store.dispatch(inputActions.clearKeys()); + return true; + } + + keymaps() { + let settings = this.store.getState().setting.settings; + if (!settings || !settings.json) { + return null; + } + return JSON.parse(settings.json).keymaps; + } +} diff --git a/src/content/index.js b/src/content/index.js index b29118d..d380291 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -1,58 +1,41 @@ import './console-frame.scss'; import * as consoleFrames from './console-frames'; -import * as scrolls from 'content/scrolls'; -import * as navigates from 'content/navigates'; -import * as followActions from 'actions/follow'; +import * as settingActions from 'actions/setting'; import { createStore } from 'store'; import ContentInputComponent from 'components/content-input'; +import KeymapperComponent from 'components/keymapper'; import FollowComponent from 'components/follow'; import reducers from 'reducers'; -import operations from 'shared/operations'; import messages from './messages'; const store = createStore(reducers); const followComponent = new FollowComponent(window.document.body, store); +const contentInputComponent = + new ContentInputComponent(window.document.body, store); +const keymapperComponent = new KeymapperComponent(store); +contentInputComponent.onKey((key, ctrl) => { + return followComponent.key(key, ctrl); +}); +contentInputComponent.onKey((key, ctrl) => { + return keymapperComponent.key(key, ctrl); +}); store.subscribe(() => { try { followComponent.update(); + contentInputComponent.update(); } catch (e) { console.error(e); } }); -// eslint-disable-next-line no-unused-vars -const contentInputComponent = new ContentInputComponent(window); consoleFrames.initialize(window.document); -const execOperation = (operation) => { - switch (operation.type) { - case operations.SCROLL_LINES: - return scrolls.scrollLines(window, operation.count); - case operations.SCROLL_PAGES: - return scrolls.scrollPages(window, operation.count); - case operations.SCROLL_TOP: - return scrolls.scrollTop(window); - case operations.SCROLL_BOTTOM: - return scrolls.scrollBottom(window); - case operations.SCROLL_HOME: - return scrolls.scrollLeft(window); - case operations.SCROLL_END: - return scrolls.scrollRight(window); - case operations.FOLLOW_START: - return store.dispatch(followActions.enable(false)); - case operations.NAVIGATE_HISTORY_PREV: - return navigates.historyPrev(window); - case operations.NAVIGATE_HISTORY_NEXT: - return navigates.historyNext(window); - case operations.NAVIGATE_LINK_PREV: - return navigates.linkPrev(window); - case operations.NAVIGATE_LINK_NEXT: - return navigates.linkNext(window); - case operations.NAVIGATE_PARENT: - return navigates.parent(window); - case operations.NAVIGATE_ROOT: - return navigates.root(window); - } +const reloadSettings = () => { + return browser.runtime.sendMessage({ + type: messages.SETTINGS_QUERY, + }).then((settings) => { + store.dispatch(settingActions.set(settings)); + }); }; browser.runtime.onMessage.addListener((action) => { @@ -61,10 +44,11 @@ browser.runtime.onMessage.addListener((action) => { window.focus(); consoleFrames.blur(window.document); return Promise.resolve(); - case messages.CONTENT_OPERATION: - execOperation(action.operation); - return Promise.resolve(); + case messages.SETTINGS_CHANGED: + return reloadSettings(); default: return Promise.resolve(); } }); + +reloadSettings(); diff --git a/src/content/messages.js b/src/content/messages.js index 0e66fa0..138f0e0 100644 --- a/src/content/messages.js +++ b/src/content/messages.js @@ -1,5 +1,7 @@ export default { - CONTENT_OPERATION: 'content.operation', + SETTINGS_QUERY: 'settings.query', + + BACKGROUND_OPERATION: 'background.operation', CONSOLE_BLURRED: 'console.blured', CONSOLE_ENTERED: 'console.entered', @@ -8,8 +10,6 @@ export default { CONSOLE_SHOW_ERROR: 'console.show.error', CONSOLE_HIDE: 'console.hide', - KEYDOWN: 'keydown', - OPEN_URL: 'open.url', SETTINGS_RELOAD: 'settings.reload', diff --git a/src/reducers/follow.js b/src/reducers/follow.js index a2397b4..ed875e8 100644 --- a/src/reducers/follow.js +++ b/src/reducers/follow.js @@ -3,7 +3,7 @@ import actions from 'actions'; const defaultState = { enabled: false, newTab: false, - keys: [], + keys: '', }; export default function reducer(state = defaultState, action = {}) { @@ -12,6 +12,7 @@ export default function reducer(state = defaultState, action = {}) { return Object.assign({}, state, { enabled: true, newTab: action.newTab, + keys: '', }); case actions.FOLLOW_DISABLE: return Object.assign({}, state, { @@ -19,7 +20,7 @@ export default function reducer(state = defaultState, action = {}) { }); case actions.FOLLOW_KEY_PRESS: return Object.assign({}, state, { - keys: state.keys.concat([action.key]), + keys: state.keys + action.key, }); case actions.FOLLOW_BACKSPACE: return Object.assign({}, state, { diff --git a/test/components/follow.test.js b/test/components/follow.test.js index c83e211..294bfc9 100644 --- a/test/components/follow.test.js +++ b/test/components/follow.test.js @@ -2,16 +2,6 @@ import { expect } from "chai"; import FollowComponent from 'components/follow'; describe('FollowComponent', () => { - describe('#codeChars', () => { - it('returns a string for key codes', () => { - let chars = [ - KeyboardEvent.DOM_VK_0, KeyboardEvent.DOM_VK_1, - KeyboardEvent.DOM_VK_A, KeyboardEvent.DOM_VK_B]; - expect(FollowComponent.codeChars(chars)).to.equal('01ab'); - expect(FollowComponent.codeChars([])).to.be.equal(''); - }); - }); - describe('#getTargetElements', () => { beforeEach(() => { document.body.innerHTML = __html__['test/components/follow.html']; diff --git a/test/reducers/follow.test.js b/test/reducers/follow.test.js index 79e75d4..e1db680 100644 --- a/test/reducers/follow.test.js +++ b/test/reducers/follow.test.js @@ -7,7 +7,7 @@ describe('follow reducer', () => { let state = followReducer(undefined, {}); expect(state).to.have.property('enabled', false); expect(state).to.have.property('newTab'); - expect(state).to.have.deep.property('keys', []); + expect(state).to.have.deep.property('keys', ''); }); it ('returns next state for FOLLOW_ENABLE', () => { @@ -15,6 +15,7 @@ describe('follow reducer', () => { let state = followReducer({ enabled: false, newTab: false }, action); expect(state).to.have.property('enabled', true); expect(state).to.have.property('newTab', true); + expect(state).to.have.property('keys', ''); }); it ('returns next state for FOLLOW_DISABLE', () => { @@ -24,24 +25,24 @@ describe('follow reducer', () => { }); it ('returns next state for FOLLOW_KEY_PRESS', () => { - let action = { type: actions.FOLLOW_KEY_PRESS, key: 100}; - let state = followReducer({ keys: [] }, action); - expect(state).to.have.deep.property('keys', [100]); + let action = { type: actions.FOLLOW_KEY_PRESS, key: 'a'}; + let state = followReducer({ keys: '' }, action); + expect(state).to.have.deep.property('keys', 'a'); - action = { type: actions.FOLLOW_KEY_PRESS, key: 200}; + action = { type: actions.FOLLOW_KEY_PRESS, key: 'b'}; state = followReducer(state, action); - expect(state).to.have.deep.property('keys', [100, 200]); + expect(state).to.have.deep.property('keys', 'ab'); }); it ('returns next state for FOLLOW_BACKSPACE', () => { let action = { type: actions.FOLLOW_BACKSPACE }; - let state = followReducer({ keys: [100, 200] }, action); - expect(state).to.have.deep.property('keys', [100]); + let state = followReducer({ keys: 'ab' }, action); + expect(state).to.have.deep.property('keys', 'a'); state = followReducer(state, action); - expect(state).to.have.deep.property('keys', []); + expect(state).to.have.deep.property('keys', ''); state = followReducer(state, action); - expect(state).to.have.deep.property('keys', []); + expect(state).to.have.deep.property('keys', ''); }); });