Merge branch 'follow-hints'

jh-changes
Shin'ya Ueoka 7 years ago
commit ab5fd9a336
  1. 9
      karma.conf.js
  2. 6
      package-lock.json
  3. 1
      package.json
  4. 2
      src/background/key-queue.js
  5. 123
      src/content/follow.js
  6. 33
      src/content/hint-key-producer.js
  7. 7
      src/content/hint.css
  8. 43
      src/content/hint.js
  9. 4
      src/content/index.js
  10. 4
      src/shared/actions.js
  11. 9
      test/content/follow.html
  12. 25
      test/content/follow.test.js
  13. 25
      test/content/hint-key-producer.test.js
  14. 1
      test/content/hint.html
  15. 58
      test/content/hint.test.js

@ -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

@ -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 {

@ -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();
}
}
}

@ -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) => {

@ -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
});
});