diff --git a/src/actions/follow.js b/src/actions/follow.js new file mode 100644 index 0000000..7ab689e --- /dev/null +++ b/src/actions/follow.js @@ -0,0 +1,29 @@ +import actions from '../actions'; + +const enable = (newTab) => { + return { + type: actions.FOLLOW_ENABLE, + newTab, + }; +}; + +const disable = () => { + return { + type: actions.FOLLOW_DISABLE, + }; +}; + +const keyPress = (key) => { + return { + type: actions.FOLLOW_KEY_PRESS, + key: key + }; +}; + +const backspace = () => { + return { + type: actions.FOLLOW_BACKSPACE, + }; +}; + +export { enable, disable, keyPress, backspace }; diff --git a/src/actions/index.js b/src/actions/index.js index 63c36d2..4e8d4a7 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -17,4 +17,10 @@ export default { // Settings SETTING_SET_SETTINGS: 'setting.set.settings', + + // Follow + FOLLOW_ENABLE: 'follow.enable', + FOLLOW_DISABLE: 'follow.disable', + FOLLOW_KEY_PRESS: 'follow.key.press', + FOLLOW_BACKSPACE: 'follow.backspace', }; diff --git a/src/components/follow.js b/src/components/follow.js new file mode 100644 index 0000000..4fe4c58 --- /dev/null +++ b/src/components/follow.js @@ -0,0 +1,210 @@ +import * as followActions from '../actions/follow'; +import messages from '../content/messages'; +import Hint from '../content/hint'; +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 + } = element.getBoundingClientRect(); + return ( + top >= 0 && left >= 0 && + bottom <= (window.innerHeight || document.documentElement.clientHeight) && + right <= (window.innerWidth || document.documentElement.clientWidth) + ); +}; + +export default class FollowComponent { + constructor(wrapper, store) { + this.wrapper = wrapper; + this.store = store; + this.hintElements = {}; + this.state = {}; + + let doc = wrapper.ownerDocument; + doc.addEventListener('keydown', this.onKeyDown.bind(this)); + } + + update() { + let prevState = this.state; + this.state = this.store.getState(); + if (!prevState.enabled && this.state.enabled) { + this.create(); + } else if (prevState.enabled && !this.state.enabled) { + this.remove(); + } else if (JSON.stringify(prevState.keys) !== + JSON.stringify(this.state.keys)) { + this.updateHints(); + } + } + + onKeyDown(e) { + if (!this.state.enabled) { + return; + } + + 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); + return; + case KeyboardEvent.DOM_VK_ESCAPE: + this.store.dispatch(followActions.disable()); + return; + case KeyboardEvent.DOM_VK_BACK_SPACE: + case KeyboardEvent.DOM_VK_DELETE: + this.store.dispatch(followActions.backspace()); + break; + default: + if (availableKey(keyCode)) { + this.store.dispatch(followActions.keyPress(keyCode)); + } + break; + } + + e.stopPropagation(); + e.preventDefault(); + } + + updateHints() { + let chars = FollowComponent.codeChars(this.state.keys); + let shown = Object.keys(this.hintElements).filter((key) => { + return key.startsWith(chars); + }); + let hidden = Object.keys(this.hintElements).filter((key) => { + return !key.startsWith(chars); + }); + if (shown.length === 0) { + this.remove(); + return; + } else if (shown.length === 1) { + this.activate(this.hintElements[chars].target); + this.remove(); + } + + shown.forEach((key) => { + this.hintElements[key].show(); + }); + hidden.forEach((key) => { + this.hintElements[key].hide(); + }); + } + + activate(element) { + switch (element.tagName.toLowerCase()) { + case 'a': + if (this.state.newTab) { + // getAttribute() to avoid to resolve absolute path + let href = element.getAttribute('href'); + + // eslint-disable-next-line no-script-url + if (!href || href === '#' || href.startsWith('javascript:')) { + return; + } + return browser.runtime.sendMessage({ + type: messages.OPEN_URL, + url: element.href, + newTab: this.state.newTab, + }); + } + if (element.href.startsWith('http://') || + element.href.startsWith('https://') || + element.href.startsWith('ftp://')) { + return browser.runtime.sendMessage({ + type: messages.OPEN_URL, + url: element.href, + newTab: this.state.newTab, + }); + } + return element.click(); + case 'input': + switch (element.type) { + case 'file': + case 'checkbox': + case 'radio': + case 'submit': + case 'reset': + case 'button': + case 'image': + case 'color': + return element.click(); + default: + return element.focus(); + } + case 'textarea': + return element.focus(); + case 'button': + return element.click(); + } + } + + create() { + let doc = this.wrapper.ownerDocument; + let elements = FollowComponent.getTargetElements(doc); + let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); + let hintElements = {}; + Array.prototype.forEach.call(elements, (ele) => { + let keys = producer.produce(); + let hint = new Hint(ele, keys); + hintElements[keys] = hint; + }); + this.hintElements = hintElements; + } + + remove() { + let hintElements = this.hintElements; + Object.keys(this.hintElements).forEach((key) => { + hintElements[key].remove(); + }); + } + + 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) => { + let style = window.getComputedStyle(element); + return style.display !== 'none' && + style.visibility !== 'hidden' && + element.type !== 'hidden' && + element.offsetHeight > 0 && + inWindow(window, element); + }); + return filtered; + } +} diff --git a/src/content/follow.js b/src/content/follow.js deleted file mode 100644 index b1d2f5c..0000000 --- a/src/content/follow.js +++ /dev/null @@ -1,149 +0,0 @@ -import Hint from './hint'; -import HintKeyProducer from './hint-key-producer'; - -const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; - -export default class Follow { - constructor(doc) { - this.doc = doc; - this.hintElements = {}; - this.keys = []; - this.onActivatedCallbacks = []; - - let links = Follow.getTargetElements(doc); - - this.addHints(links); - - this.boundKeydown = this.handleKeydown.bind(this); - doc.addEventListener('keydown', this.boundKeydown); - } - - addHints(elements) { - let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); - Array.prototype.forEach.call(elements, (ele) => { - let keys = producer.produce(); - let hint = new Hint(ele, keys); - - this.hintElements[keys] = hint; - }); - } - - handleKeydown(e) { - let { keyCode } = e; - if (keyCode === KeyboardEvent.DOM_VK_ESCAPE) { - this.remove(); - return; - } else if (keyCode === KeyboardEvent.DOM_VK_ENTER || - keyCode === KeyboardEvent.DOM_VK_RETURN) { - let chars = Follow.codeChars(this.keys); - this.activate(this.hintElements[chars].target); - return; - } else if (Follow.availableKey(keyCode)) { - this.keys.push(keyCode); - } else if (keyCode === KeyboardEvent.DOM_VK_BACK_SPACE || - keyCode === KeyboardEvent.DOM_VK_DELETE) { - this.keys.pop(); - } - - e.stopPropagation(); - e.preventDefault(); - - this.refreshKeys(); - } - - refreshKeys() { - let chars = Follow.codeChars(this.keys); - let shown = Object.keys(this.hintElements).filter((key) => { - return key.startsWith(chars); - }); - let hidden = Object.keys(this.hintElements).filter((key) => { - return !key.startsWith(chars); - }); - if (shown.length === 0) { - this.remove(); - return; - } else if (shown.length === 1) { - this.remove(); - this.activate(this.hintElements[chars].target); - } - - shown.forEach((key) => { - this.hintElements[key].show(); - }); - hidden.forEach((key) => { - this.hintElements[key].hide(); - }); - } - - remove() { - this.doc.removeEventListener('keydown', this.boundKeydown); - Object.keys(this.hintElements).forEach((key) => { - this.hintElements[key].remove(); - }); - } - - activate(element) { - this.onActivatedCallbacks.forEach(f => f(element)); - } - - onActivated(f) { - this.onActivatedCallbacks.push(f); - } - - static availableKey(keyCode) { - return ( - KeyboardEvent.DOM_VK_0 <= keyCode && keyCode <= KeyboardEvent.DOM_VK_9 || - KeyboardEvent.DOM_VK_A <= keyCode && keyCode <= KeyboardEvent.DOM_VK_Z - ); - } - - static isNumericKey(code) { - return KeyboardEvent.DOM_VK_0 <= code && code <= KeyboardEvent.DOM_VK_9; - } - - static isAlphabeticKey(code) { - return KeyboardEvent.DOM_VK_A <= code && code <= KeyboardEvent.DOM_VK_Z; - } - - static codeChars(codes) { - const CHARCODE_ZERO = '0'.charCodeAt(0); - const CHARCODE_A = 'a'.charCodeAt(0); - - let chars = ''; - - for (let code of codes) { - if (Follow.isNumericKey(code)) { - chars += String.fromCharCode( - code - KeyboardEvent.DOM_VK_0 + CHARCODE_ZERO); - } else if (Follow.isAlphabeticKey(code)) { - chars += String.fromCharCode( - code - KeyboardEvent.DOM_VK_A + CHARCODE_A); - } - } - return chars; - } - - static inWindow(window, element) { - let { - top, left, bottom, right - } = element.getBoundingClientRect(); - return ( - top >= 0 && left >= 0 && - bottom <= (window.innerHeight || document.documentElement.clientHeight) && - right <= (window.innerWidth || document.documentElement.clientWidth) - ); - } - - static getTargetElements(doc) { - let all = doc.querySelectorAll('a,button,input,textarea'); - let filtered = Array.prototype.filter.call(all, (element) => { - let style = window.getComputedStyle(element); - return style.display !== 'none' && - style.visibility !== 'hidden' && - element.type !== 'hidden' && - element.offsetHeight > 0 && - Follow.inWindow(window, element); - }); - return filtered; - } -} diff --git a/src/content/index.js b/src/content/index.js index 2e64af2..0dbc8c1 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -2,62 +2,24 @@ import './console-frame.scss'; import * as consoleFrames from './console-frames'; import * as scrolls from '../content/scrolls'; import * as navigates from '../content/navigates'; -import Follow from '../content/follow'; +import * as followActions from '../actions/follow'; +import * as store from '../store'; +import FollowComponent from '../components/follow'; +import followReducer from '../reducers/follow'; import operations from '../operations'; import messages from './messages'; -consoleFrames.initialize(window.document); - -const startFollows = (newTab) => { - let follow = new Follow(window.document); - follow.onActivated((element) => { - switch (element.tagName.toLowerCase()) { - case 'a': - if (newTab) { - // getAttribute() to avoid to resolve absolute path - let href = element.getAttribute('href'); +const followStore = store.createStore(followReducer); +const followComponent = new FollowComponent(window.document.body, followStore); +followStore.subscribe(() => { + try { + followComponent.update(); + } catch (e) { + console.error(e); + } +}); - // eslint-disable-next-line no-script-url - if (!href || href === '#' || href.startsWith('javascript:')) { - return; - } - return browser.runtime.sendMessage({ - type: messages.OPEN_URL, - url: element.href, - newTab - }); - } - if (element.href.startsWith('http://') || - element.href.startsWith('https://') || - element.href.startsWith('ftp://')) { - return browser.runtime.sendMessage({ - type: messages.OPEN_URL, - url: element.href, - newTab - }); - } - return element.click(); - case 'input': - switch (element.type) { - case 'file': - case 'checkbox': - case 'radio': - case 'submit': - case 'reset': - case 'button': - case 'image': - case 'color': - return element.click(); - default: - return element.focus(); - } - case 'textarea': - return element.focus(); - case 'button': - return element.click(); - } - }); -}; +consoleFrames.initialize(window.document); window.addEventListener('keypress', (e) => { if (e.target instanceof HTMLInputElement || @@ -90,7 +52,7 @@ const execOperation = (operation) => { case operations.SCROLL_END: return scrolls.scrollRight(window); case operations.FOLLOW_START: - return startFollows(operation.newTab); + return followStore.dispatch(followActions.enable(false)); case operations.NAVIGATE_HISTORY_PREV: return navigates.historyPrev(window); case operations.NAVIGATE_HISTORY_NEXT: diff --git a/src/reducers/follow.js b/src/reducers/follow.js new file mode 100644 index 0000000..136b367 --- /dev/null +++ b/src/reducers/follow.js @@ -0,0 +1,31 @@ +import actions from '../actions'; + +const defaultState = { + enabled: false, + newTab: false, + keys: [], +}; + +export default function reducer(state = defaultState, action = {}) { + switch (action.type) { + case actions.FOLLOW_ENABLE: + return Object.assign({}, state, { + enabled: true, + newTab: action.newTab, + }); + case actions.FOLLOW_DISABLE: + return Object.assign({}, state, { + enabled: false, + }); + case actions.FOLLOW_KEY_PRESS: + return Object.assign({}, state, { + keys: state.keys.concat([action.key]), + }); + case actions.FOLLOW_BACKSPACE: + return Object.assign({}, state, { + keys: state.keys.slice(0, -1), + }); + default: + return state; + } +} diff --git a/test/content/follow.html b/test/components/follow.html similarity index 100% rename from test/content/follow.html rename to test/components/follow.html diff --git a/test/content/follow.test.js b/test/components/follow.test.js similarity index 54% rename from test/content/follow.test.js rename to test/components/follow.test.js index fd4f0bc..f2f870e 100644 --- a/test/content/follow.test.js +++ b/test/components/follow.test.js @@ -1,24 +1,24 @@ import { expect } from "chai"; -import Follow from '../../src/content/follow'; +import FollowComponent from '../../src/components/follow'; -describe('Follow class', () => { +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(Follow.codeChars(chars)).to.equal('01ab'); - expect(Follow.codeChars([])).to.be.equal(''); + expect(FollowComponent.codeChars(chars)).to.equal('01ab'); + expect(FollowComponent.codeChars([])).to.be.equal(''); }); }); describe('#getTargetElements', () => { beforeEach(() => { - document.body.innerHTML = __html__['test/content/follow.html']; + document.body.innerHTML = __html__['test/components/follow.html']; }); it('returns visible links', () => { - let links = Follow.getTargetElements(window.document); + let links = FollowComponent.getTargetElements(window.document); expect(links).to.have.lengthOf(1); }); });