aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--karma.conf.js9
-rw-r--r--package-lock.json6
-rw-r--r--package.json1
-rw-r--r--src/background/key-queue.js2
-rw-r--r--src/content/follow.js123
-rw-r--r--src/content/hint-key-producer.js33
-rw-r--r--src/content/hint.css7
-rw-r--r--src/content/hint.js43
-rw-r--r--src/content/index.js4
-rw-r--r--src/shared/actions.js4
-rw-r--r--test/content/follow.html9
-rw-r--r--test/content/follow.test.js25
-rw-r--r--test/content/hint-key-producer.test.js25
-rw-r--r--test/content/hint.html1
-rw-r--r--test/content/hint.test.js58
15 files changed, 347 insertions, 3 deletions
diff --git a/karma.conf.js b/karma.conf.js
index 539fb3a..859cee0 100644
--- a/karma.conf.js
+++ b/karma.conf.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'],
diff --git a/package-lock.json b/package-lock.json
index 37524a7..c91d079 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 2e38ee5..c9c5425 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/background/key-queue.js b/src/background/key-queue.js
index d753bc1..5693b36 100644
--- a/src/background/key-queue.js
+++ b/src/background/key-queue.js
@@ -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 {
diff --git a/src/content/follow.js b/src/content/follow.js
new file mode 100644
index 0000000..ffa16b9
--- /dev/null
+++ b/src/content/follow.js
@@ -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;
+ }
+}
diff --git a/src/content/hint-key-producer.js b/src/content/hint-key-producer.js
new file mode 100644
index 0000000..8064afb
--- /dev/null
+++ b/src/content/hint-key-producer.js
@@ -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();
+ }
+}
diff --git a/src/content/hint.css b/src/content/hint.css
new file mode 100644
index 0000000..a0f1233
--- /dev/null
+++ b/src/content/hint.css
@@ -0,0 +1,7 @@
+.vimvixen-hint {
+ background-color: yellow;
+ border: 1px solid gold;
+ font-weight: bold;
+ position: absolute;
+ text-transform: uppercase;
+}
diff --git a/src/content/hint.js b/src/content/hint.js
new file mode 100644
index 0000000..fabf725
--- /dev/null
+++ b/src/content/hint.js
@@ -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();
+ }
+ }
+}
diff --git a/src/content/index.js b/src/content/index.js
index 17ab308..78389fd 100644
--- a/src/content/index.js
+++ b/src/content/index.js
@@ -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;
}
}
diff --git a/src/shared/actions.js b/src/shared/actions.js
index be25d72..bb61dbc 100644
--- a/src/shared/actions.js
+++ b/src/shared/actions.js
@@ -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) => {
diff --git a/test/content/follow.html b/test/content/follow.html
new file mode 100644
index 0000000..6bd8f87
--- /dev/null
+++ b/test/content/follow.html
@@ -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>
diff --git a/test/content/follow.test.js b/test/content/follow.test.js
new file mode 100644
index 0000000..fd4f0bc
--- /dev/null
+++ b/test/content/follow.test.js
@@ -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);
+ });
+ });
+});
diff --git a/test/content/hint-key-producer.test.js b/test/content/hint-key-producer.test.js
new file mode 100644
index 0000000..74fb462
--- /dev/null
+++ b/test/content/hint-key-producer.test.js
@@ -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]);
+ }
+ });
+ });
+});
diff --git a/test/content/hint.html b/test/content/hint.html
new file mode 100644
index 0000000..b50c5fe
--- /dev/null
+++ b/test/content/hint.html
@@ -0,0 +1 @@
+<a id='test-link' href='javascript:window.vimvixenTest="hello"' >link</a>
diff --git a/test/content/hint.test.js b/test/content/hint.test.js
new file mode 100644
index 0000000..9b2ab6e
--- /dev/null
+++ b/test/content/hint.test.js
@@ -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
+ });
+});
+
+