Merge branch 'follow-hints'
This commit is contained in:
commit
ab5fd9a336
15 changed files with 347 additions and 3 deletions
|
@ -1,13 +1,18 @@
|
||||||
module.exports = function (config) {
|
module.exports = function (config) {
|
||||||
|
|
||||||
var webpackConfig = require('./webpack.config.js');
|
var webpackConfig = require('./webpack.config.js');
|
||||||
|
|
||||||
config.set({
|
config.set({
|
||||||
basePath: '',
|
basePath: '',
|
||||||
frameworks: ['mocha'],
|
frameworks: ['mocha'],
|
||||||
files: ['test/**/*\.test\.js'],
|
files: [
|
||||||
|
'test/**/*.test.js',
|
||||||
|
'test/**/*.html'
|
||||||
|
],
|
||||||
|
|
||||||
preprocessors: {
|
preprocessors: {
|
||||||
'test/**/*\.test\.js': [ 'webpack' ]
|
'test/**/*.test.js': [ 'webpack' ],
|
||||||
|
'test/**/*.html': ['html2js']
|
||||||
},
|
},
|
||||||
|
|
||||||
reporters: ['progress'],
|
reporters: ['progress'],
|
||||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -3168,6 +3168,12 @@
|
||||||
"integrity": "sha1-zlj0fCATqIFW1VpdYTN8CZz1u1E=",
|
"integrity": "sha1-zlj0fCATqIFW1VpdYTN8CZz1u1E=",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"karma-html2js-preprocessor": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/karma-html2js-preprocessor/-/karma-html2js-preprocessor-1.1.0.tgz",
|
||||||
|
"integrity": "sha1-/Ant8Eu+K7bu6boZaPgmtziAIL0=",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"karma-mocha": {
|
"karma-mocha": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-1.3.0.tgz",
|
||||||
|
|
|
@ -26,6 +26,7 @@
|
||||||
"eslint": "^4.4.1",
|
"eslint": "^4.4.1",
|
||||||
"karma": "^1.7.0",
|
"karma": "^1.7.0",
|
||||||
"karma-firefox-launcher": "^1.0.1",
|
"karma-firefox-launcher": "^1.0.1",
|
||||||
|
"karma-html2js-preprocessor": "^1.1.0",
|
||||||
"karma-mocha": "^1.3.0",
|
"karma-mocha": "^1.3.0",
|
||||||
"karma-mocha-reporter": "^2.2.3",
|
"karma-mocha-reporter": "^2.2.3",
|
||||||
"karma-sourcemap-loader": "^0.3.7",
|
"karma-sourcemap-loader": "^0.3.7",
|
||||||
|
|
|
@ -13,6 +13,8 @@ const DEFAULT_KEYMAP = [
|
||||||
{ keys: [{ code: KeyboardEvent.DOM_VK_U }], action: [ actions.TABS_REOPEN]},
|
{ keys: [{ code: KeyboardEvent.DOM_VK_U }], action: [ actions.TABS_REOPEN]},
|
||||||
{ keys: [{ code: KeyboardEvent.DOM_VK_H }], action: [ actions.TABS_PREV, 1 ]},
|
{ keys: [{ code: KeyboardEvent.DOM_VK_H }], action: [ actions.TABS_PREV, 1 ]},
|
||||||
{ keys: [{ code: KeyboardEvent.DOM_VK_L }], action: [ actions.TABS_NEXT, 1 ]},
|
{ keys: [{ code: KeyboardEvent.DOM_VK_L }], action: [ actions.TABS_NEXT, 1 ]},
|
||||||
|
{ keys: [{ code: KeyboardEvent.DOM_VK_F }], action: [ actions.FOLLOW_START, false ]},
|
||||||
|
{ keys: [{ code: KeyboardEvent.DOM_VK_F, shift: true }], action: [ actions.FOLLOW_START, true ]},
|
||||||
]
|
]
|
||||||
|
|
||||||
export default class KeyQueue {
|
export default class KeyQueue {
|
||||||
|
|
123
src/content/follow.js
Normal file
123
src/content/follow.js
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
33
src/content/hint-key-producer.js
Normal file
33
src/content/hint-key-producer.js
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
7
src/content/hint.css
Normal file
7
src/content/hint.css
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.vimvixen-hint {
|
||||||
|
background-color: yellow;
|
||||||
|
border: 1px solid gold;
|
||||||
|
font-weight: bold;
|
||||||
|
position: absolute;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
43
src/content/hint.js
Normal file
43
src/content/hint.js
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,6 @@
|
||||||
import * as scrolls from './scrolls';
|
import * as scrolls from './scrolls';
|
||||||
import FooterLine from './footer-line';
|
import FooterLine from './footer-line';
|
||||||
|
import Follow from './follow';
|
||||||
import * as actions from '../shared/actions';
|
import * as actions from '../shared/actions';
|
||||||
|
|
||||||
var footer = null;
|
var footer = null;
|
||||||
|
@ -52,6 +53,9 @@ const invokeEvent = (action) => {
|
||||||
case actions.SCROLL_BOTTOM:
|
case actions.SCROLL_BOTTOM:
|
||||||
scrolls.scrollBottom(window, action[1]);
|
scrolls.scrollBottom(window, action[1]);
|
||||||
break;
|
break;
|
||||||
|
case actions.FOLLOW_START:
|
||||||
|
new Follow(window.document, action[1] || false);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ export const SCROLL_UP = 'scroll.up';
|
||||||
export const SCROLL_DOWN = 'scroll.down';
|
export const SCROLL_DOWN = 'scroll.down';
|
||||||
export const SCROLL_TOP = 'scroll.top';
|
export const SCROLL_TOP = 'scroll.top';
|
||||||
export const SCROLL_BOTTOM = 'scroll.bottom';
|
export const SCROLL_BOTTOM = 'scroll.bottom';
|
||||||
|
export const FOLLOW_START = 'follow.start';
|
||||||
|
|
||||||
const BACKGROUND_ACTION_SET = new Set([
|
const BACKGROUND_ACTION_SET = new Set([
|
||||||
TABS_CLOSE,
|
TABS_CLOSE,
|
||||||
|
@ -22,7 +23,8 @@ const CONTENT_ACTION_SET = new Set([
|
||||||
SCROLL_UP,
|
SCROLL_UP,
|
||||||
SCROLL_DOWN,
|
SCROLL_DOWN,
|
||||||
SCROLL_TOP,
|
SCROLL_TOP,
|
||||||
SCROLL_BOTTOM
|
SCROLL_BOTTOM,
|
||||||
|
FOLLOW_START
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const isBackgroundAction = (action) => {
|
export const isBackgroundAction = (action) => {
|
||||||
|
|
9
test/content/follow.html
Normal file
9
test/content/follow.html
Normal file
|
@ -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>
|
25
test/content/follow.test.js
Normal file
25
test/content/follow.test.js
Normal file
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
25
test/content/hint-key-producer.test.js
Normal file
25
test/content/hint-key-producer.test.js
Normal file
|
@ -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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
1
test/content/hint.html
Normal file
1
test/content/hint.html
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<a id='test-link' href='javascript:window.vimvixenTest="hello"' >link</a>
|
58
test/content/hint.test.js
Normal file
58
test/content/hint.test.js
Normal file
|
@ -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 a new issue