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) {
|
||||
|
||||
var webpackConfig = require('./webpack.config.js');
|
||||
|
||||
config.set({
|
||||
basePath: '',
|
||||
frameworks: ['mocha'],
|
||||
files: ['test/**/*\.test\.js'],
|
||||
files: [
|
||||
'test/**/*.test.js',
|
||||
'test/**/*.html'
|
||||
],
|
||||
|
||||
preprocessors: {
|
||||
'test/**/*\.test\.js': [ 'webpack' ]
|
||||
'test/**/*.test.js': [ 'webpack' ],
|
||||
'test/**/*.html': ['html2js']
|
||||
},
|
||||
|
||||
reporters: ['progress'],
|
||||
|
|
6
package-lock.json
generated
6
package-lock.json
generated
|
@ -3168,6 +3168,12 @@
|
|||
"integrity": "sha1-zlj0fCATqIFW1VpdYTN8CZz1u1E=",
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/karma-mocha/-/karma-mocha-1.3.0.tgz",
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
"eslint": "^4.4.1",
|
||||
"karma": "^1.7.0",
|
||||
"karma-firefox-launcher": "^1.0.1",
|
||||
"karma-html2js-preprocessor": "^1.1.0",
|
||||
"karma-mocha": "^1.3.0",
|
||||
"karma-mocha-reporter": "^2.2.3",
|
||||
"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_H }], action: [ actions.TABS_PREV, 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 {
|
||||
|
|
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 FooterLine from './footer-line';
|
||||
import Follow from './follow';
|
||||
import * as actions from '../shared/actions';
|
||||
|
||||
var footer = null;
|
||||
|
@ -52,6 +53,9 @@ const invokeEvent = (action) => {
|
|||
case actions.SCROLL_BOTTOM:
|
||||
scrolls.scrollBottom(window, action[1]);
|
||||
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_TOP = 'scroll.top';
|
||||
export const SCROLL_BOTTOM = 'scroll.bottom';
|
||||
export const FOLLOW_START = 'follow.start';
|
||||
|
||||
const BACKGROUND_ACTION_SET = new Set([
|
||||
TABS_CLOSE,
|
||||
|
@ -22,7 +23,8 @@ const CONTENT_ACTION_SET = new Set([
|
|||
SCROLL_UP,
|
||||
SCROLL_DOWN,
|
||||
SCROLL_TOP,
|
||||
SCROLL_BOTTOM
|
||||
SCROLL_BOTTOM,
|
||||
FOLLOW_START
|
||||
]);
|
||||
|
||||
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