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