commit
ab5fd9a336
15 changed files with 347 additions and 3 deletions
@ -0,0 +1,123 @@ |
||||
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 = []; |
||||
|
||||
// TODO activate input elements and push button elements
|
||||
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.keyCode; |
||||
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.hintElements[chars].activate(); |
||||
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(); |
||||
} |
||||
|
||||
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.hintElements[chars].activate(); |
||||
} |
||||
|
||||
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(); |
||||
}); |
||||
} |
||||
|
||||
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 codeChars(codes) { |
||||
const CHARCODE_ZERO = '0'.charCodeAt(0); |
||||
const CHARCODE_A = 'a'.charCodeAt(0); |
||||
|
||||
let chars = ''; |
||||
|
||||
for (let code of codes) { |
||||
if (KeyboardEvent.DOM_VK_0 <= code && code <= KeyboardEvent.DOM_VK_9) { |
||||
chars += String.fromCharCode(code - KeyboardEvent.DOM_VK_0 + CHARCODE_ZERO); |
||||
} else if (KeyboardEvent.DOM_VK_A <= code && code <= KeyboardEvent.DOM_VK_Z) { |
||||
chars += String.fromCharCode(code - KeyboardEvent.DOM_VK_A + CHARCODE_A); |
||||
} |
||||
} |
||||
return chars; |
||||
} |
||||
|
||||
static getTargetElements(doc) { |
||||
let all = doc.querySelectorAll('a'); |
||||
let filtered = Array.prototype.filter.call(all, (e) => { |
||||
return Follow.isVisibleElement(e); |
||||
}); |
||||
return filtered; |
||||
} |
||||
|
||||
static isVisibleElement(element) { |
||||
var style = window.getComputedStyle(element); |
||||
if (style.display === 'none') { |
||||
return false; |
||||
} else if (style.visibility === 'hidden') { |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,33 @@ |
||||
export default class HintKeyProducer { |
||||
constructor(charset) { |
||||
if (charset.length === 0) { |
||||
throw new TypeError('charset is empty'); |
||||
} |
||||
|
||||
this.charset = charset; |
||||
this.counter = []; |
||||
} |
||||
|
||||
produce() { |
||||
this.increment(); |
||||
|
||||
return this.counter.map((x) => this.charset[x]).join(''); |
||||
} |
||||
|
||||
increment() { |
||||
let max = this.charset.length - 1; |
||||
if (this.counter.every((x) => x == max)) { |
||||
this.counter = new Array(this.counter.length + 1).fill(0); |
||||
return; |
||||
} |
||||
|
||||
this.counter.reverse(); |
||||
let len = this.charset.length; |
||||
let num = this.counter.reduce((x,y,index) => x + y * (len ** index)) + 1; |
||||
for (let i = 0; i < this.counter.length; ++i) { |
||||
this.counter[i] = num % len; |
||||
num = ~~(num / len); |
||||
} |
||||
this.counter.reverse(); |
||||
} |
||||
} |
@ -0,0 +1,7 @@ |
||||
.vimvixen-hint { |
||||
background-color: yellow; |
||||
border: 1px solid gold; |
||||
font-weight: bold; |
||||
position: absolute; |
||||
text-transform: uppercase; |
||||
} |
@ -0,0 +1,43 @@ |
||||
import './hint.css'; |
||||
|
||||
export default class Hint { |
||||
constructor(target, tag) { |
||||
if (!(document.body instanceof HTMLElement)) { |
||||
throw new TypeError('target is not an HTMLElement'); |
||||
} |
||||
|
||||
this.target = target; |
||||
|
||||
let doc = target.ownerDocument |
||||
let { top, left } = target.getBoundingClientRect(); |
||||
let scrollX = window.scrollX; |
||||
let scrollY = window.scrollY; |
||||
|
||||
this.element = doc.createElement('span'); |
||||
this.element.className = 'vimvixen-hint'; |
||||
this.element.textContent = tag; |
||||
this.element.style.left = left + scrollX + 'px'; |
||||
this.element.style.top = top + scrollY + 'px'; |
||||
|
||||
this.show(); |
||||
doc.body.append(this.element); |
||||
} |
||||
|
||||
show() { |
||||
this.element.style.display = 'inline'; |
||||
} |
||||
|
||||
hide() { |
||||
this.element.style.display = 'none'; |
||||
} |
||||
|
||||
remove() { |
||||
this.element.remove(); |
||||
} |
||||
|
||||
activate() { |
||||
if (this.target.tagName.toLowerCase() === 'a') { |
||||
this.target.click(); |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,9 @@ |
||||
<!DOCTYPE html> |
||||
<html> |
||||
<body> |
||||
<a href='#' >link</a> |
||||
<a href='#' style='display:none'>invisible 1</a> |
||||
<a href='#' style='visibility:hidden'>invisible 2</a> |
||||
<i>not link<i> |
||||
</body> |
||||
</html> |
@ -0,0 +1,25 @@ |
||||
import { expect } from "chai"; |
||||
import Follow from '../../src/content/follow'; |
||||
|
||||
describe('Follow class', () => { |
||||
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(''); |
||||
}); |
||||
}); |
||||
|
||||
describe('#getTargetElements', () => { |
||||
beforeEach(() => { |
||||
document.body.innerHTML = __html__['test/content/follow.html']; |
||||
}); |
||||
|
||||
it('returns visible links', () => { |
||||
let links = Follow.getTargetElements(window.document); |
||||
expect(links).to.have.lengthOf(1); |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1,25 @@ |
||||
import { expect } from "chai"; |
||||
import HintKeyProducer from '../../src/content/hint-key-producer'; |
||||
|
||||
describe('HintKeyProducer class', () => { |
||||
describe('#constructor', () => { |
||||
it('throws an exception on empty charset', () => { |
||||
expect(() => new HintKeyProducer([])).to.throw(TypeError); |
||||
}); |
||||
}); |
||||
|
||||
describe('#produce', () => { |
||||
it('produce incremented keys', () => { |
||||
let charset = 'abc'; |
||||
let sequences = [ |
||||
'a', 'b', 'c', |
||||
'aa', 'ab', 'ac', 'ba', 'bb', 'bc', 'ca', 'cb', 'cc', |
||||
'aaa', 'aab', 'aac', 'aba'] |
||||
|
||||
let producer = new HintKeyProducer(charset); |
||||
for (let i = 0; i < sequences.length; ++i) { |
||||
expect(producer.produce()).to.equal(sequences[i]); |
||||
} |
||||
}); |
||||
}); |
||||
}); |
@ -0,0 +1 @@ |
||||
<a id='test-link' href='javascript:window.vimvixenTest="hello"' >link</a> |
@ -0,0 +1,58 @@ |
||||
import { expect } from "chai"; |
||||
import Hint from '../../src/content/hint'; |
||||
|
||||
describe('Hint class', () => { |
||||
beforeEach(() => { |
||||
document.body.innerHTML = __html__['test/content/hint.html']; |
||||
}); |
||||
|
||||
describe('#constructor', () => { |
||||
it('creates a hint element with tag name', () => { |
||||
let link = document.getElementById('test-link'); |
||||
let hint = new Hint(link, 'abc'); |
||||
expect(hint.element.textContent.trim()).to.be.equal('abc'); |
||||
}); |
||||
|
||||
it('throws an exception when non-element given', () => { |
||||
expect(() => new Hint(window, 'abc')).to.throw(TypeError); |
||||
}); |
||||
}); |
||||
|
||||
describe('#show', () => { |
||||
it('shows an element', () => { |
||||
let link = document.getElementById('test-link'); |
||||
let hint = new Hint(link, 'abc'); |
||||
hint.hide(); |
||||
hint.show(); |
||||
|
||||
expect(hint.element.style.display).to.not.equal('none'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#hide', () => { |
||||
it('hides an element', () => { |
||||
let link = document.getElementById('test-link'); |
||||
let hint = new Hint(link, 'abc'); |
||||
hint.hide(); |
||||
|
||||
expect(hint.element.style.display).to.equal('none'); |
||||
}); |
||||
}); |
||||
|
||||
describe('#remove', () => { |
||||
it('removes an element', () => { |
||||
let link = document.getElementById('test-link'); |
||||
let hint = new Hint(link, 'abc'); |
||||
|
||||
expect(hint.element.parentElement).to.not.be.null; |
||||
hint.remove(); |
||||
expect(hint.element.parentElement).to.be.null; |
||||
}); |
||||
}); |
||||
|
||||
describe('#activate', () => { |
||||
// TODO test activations
|
||||
}); |
||||
}); |
||||
|
||||
|
Reference in new issue