parent
6f857e2c81
commit
0a7ae631cd
8 changed files with 297 additions and 208 deletions
@ -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 }; |
@ -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; |
||||
} |
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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; |
||||
} |
||||
} |
@ -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); |
||||
}); |
||||
}); |
Reference in new issue