From f1c920e0003238a8f319fd29cd7aea068fd4f231 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Thu, 17 Aug 2017 22:33:46 +0900 Subject: [PATCH 1/9] add HintKeyProducer --- src/content/hint-key-producer.js | 33 ++++++++++++++++++++++++++ test/content/hint-key-producer.test.js | 25 +++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/content/hint-key-producer.js create mode 100644 test/content/hint-key-producer.test.js 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/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]); + } + }); + }); +}); From 99c1b831330462442e4c41553c29887ecdd96583 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 20 Aug 2017 18:11:52 +0900 Subject: [PATCH 2/9] add hint element wrapper --- src/content/hint.css | 7 +++++++ src/content/hint.js | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/content/hint.css create mode 100644 src/content/hint.js 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..7979cf1 --- /dev/null +++ b/src/content/hint.js @@ -0,0 +1,38 @@ +import './hint.css'; + +export default class Hint { + constructor(target, tag) { + this.target = target; + + let doc = target.ownerDocument + let { top, left } = target.getBoundingClientRect(); + + this.element = doc.createElement('span'); + this.element.className = 'vimvixen-hint'; + this.element.textContent = tag; + this.element.style.top = top + 'px'; + this.element.style.left = left + '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') { + let href = this.target.href; + window.location.href = href; + } + } +} From 355da18d3d6232620ebd7548f8d41b6f1af5aa08 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 20 Aug 2017 21:19:42 +0900 Subject: [PATCH 3/9] add follow fot a tags --- src/background/key-queue.js | 2 + src/content/follow.js | 110 ++++++++++++++++++++++++++++++++++++ src/content/index.js | 4 ++ src/shared/actions.js | 4 +- 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 src/content/follow.js 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..c0b7a44 --- /dev/null +++ b/src/content/follow.js @@ -0,0 +1,110 @@ +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) { + this.openUrl(this.keys); + 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(); + } + + + let keysAsString = Follow.codeChars(this.keys); + let shown = Object.keys(this.hintElements).filter((key) => { + return key.startsWith(keysAsString); + }); + let hidden = Object.keys(this.hintElements).filter((key) => { + return !key.startsWith(keysAsString); + }); + if (shown.length == 0) { + this.remove(); + return; + } else if (shown.length == 1) { + this.openUrl(this.keys); + return; + } + + 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(); + }); + } + + openUrl(keys) { + let chars = Follow.codeChars(keys); + this.hintElements[chars].activate(); + } + + 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) { + return doc.querySelectorAll('a') + } +} 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) => { From 55147feb8e64a81ac665575aa6801d315da85e88 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 20 Aug 2017 21:34:06 +0900 Subject: [PATCH 4/9] add Follow test --- test/content/follow.test.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 test/content/follow.test.js diff --git a/test/content/follow.test.js b/test/content/follow.test.js new file mode 100644 index 0000000..eb3d679 --- /dev/null +++ b/test/content/follow.test.js @@ -0,0 +1,14 @@ +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(''); + }); + }); +}); From d4d54ca4963ef4de3e913746cdee87656dc02229 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Mon, 21 Aug 2017 20:45:56 +0900 Subject: [PATCH 5/9] install karma-html2js-preprocessor --- karma.conf.js | 9 +++++++-- package-lock.json | 6 ++++++ package.json | 1 + 3 files changed, 14 insertions(+), 2 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", From c50f463bc12da7e3a5de490b714b4ff1ea8d3e56 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Mon, 21 Aug 2017 21:01:29 +0900 Subject: [PATCH 6/9] add follow tests --- src/content/follow.js | 16 +++++++++++++++- test/content/follow.html | 9 +++++++++ test/content/follow.test.js | 11 +++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 test/content/follow.html diff --git a/src/content/follow.js b/src/content/follow.js index c0b7a44..d678351 100644 --- a/src/content/follow.js +++ b/src/content/follow.js @@ -105,6 +105,20 @@ export default class Follow { } static getTargetElements(doc) { - return doc.querySelectorAll('a') + 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/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 @@ + + + + link + invisible 1 + invisible 2 + not link + + diff --git a/test/content/follow.test.js b/test/content/follow.test.js index eb3d679..fd4f0bc 100644 --- a/test/content/follow.test.js +++ b/test/content/follow.test.js @@ -11,4 +11,15 @@ describe('Follow class', () => { 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); + }); + }); }); From a052ec92b7c7f27447211222231f43f07c2990c8 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Mon, 21 Aug 2017 22:19:01 +0900 Subject: [PATCH 7/9] add Hint tests --- src/content/hint.js | 7 +++-- test/content/hint.html | 1 + test/content/hint.test.js | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 test/content/hint.html create mode 100644 test/content/hint.test.js diff --git a/src/content/hint.js b/src/content/hint.js index 7979cf1..f59899d 100644 --- a/src/content/hint.js +++ b/src/content/hint.js @@ -2,6 +2,10 @@ 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 @@ -31,8 +35,7 @@ export default class Hint { activate() { if (this.target.tagName.toLowerCase() === 'a') { - let href = this.target.href; - window.location.href = href; + this.target.click(); } } } 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 @@ +link 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 + }); +}); + + From 685164629da8a28fae19128a198ba6b9a57e55f9 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Tue, 22 Aug 2017 21:51:29 +0900 Subject: [PATCH 8/9] remove follow on activated --- src/content/follow.js | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/src/content/follow.js b/src/content/follow.js index d678351..ffa16b9 100644 --- a/src/content/follow.js +++ b/src/content/follow.js @@ -35,7 +35,8 @@ export default class Follow { return; } else if (keyCode === KeyboardEvent.DOM_VK_ENTER || keyCode === KeyboardEvent.DOM_VK_RETURN) { - this.openUrl(this.keys); + let chars = Follow.codeChars(this.keys); + this.hintElements[chars].activate(); return; } else if (Follow.availableKey(keyCode)) { this.keys.push(keyCode); @@ -44,20 +45,23 @@ export default class Follow { this.keys.pop(); } + this.refreshKeys(); + } - let keysAsString = Follow.codeChars(this.keys); + refreshKeys() { + let chars = Follow.codeChars(this.keys); let shown = Object.keys(this.hintElements).filter((key) => { - return key.startsWith(keysAsString); + return key.startsWith(chars); }); let hidden = Object.keys(this.hintElements).filter((key) => { - return !key.startsWith(keysAsString); + return !key.startsWith(chars); }); if (shown.length == 0) { this.remove(); return; } else if (shown.length == 1) { - this.openUrl(this.keys); - return; + this.remove(); + this.hintElements[chars].activate(); } shown.forEach((key) => { @@ -76,11 +80,6 @@ export default class Follow { }); } - openUrl(keys) { - let chars = Follow.codeChars(keys); - this.hintElements[chars].activate(); - } - static availableKey(keyCode) { return ( KeyboardEvent.DOM_VK_0 <= keyCode && keyCode <= KeyboardEvent.DOM_VK_9 || From dcebe336281d068649927a8bb8d3c0403807ef01 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Tue, 22 Aug 2017 21:55:16 +0900 Subject: [PATCH 9/9] add scroll to hint position --- src/content/hint.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/content/hint.js b/src/content/hint.js index f59899d..fabf725 100644 --- a/src/content/hint.js +++ b/src/content/hint.js @@ -10,12 +10,14 @@ export default class Hint { 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.top = top + 'px'; - this.element.style.left = left + 'px'; + this.element.style.left = left + scrollX + 'px'; + this.element.style.top = top + scrollY + 'px'; this.show(); doc.body.append(this.element);