diff options
| -rw-r--r-- | karma.conf.js | 9 | ||||
| -rw-r--r-- | package-lock.json | 6 | ||||
| -rw-r--r-- | package.json | 1 | ||||
| -rw-r--r-- | src/background/key-queue.js | 2 | ||||
| -rw-r--r-- | src/content/follow.js | 123 | ||||
| -rw-r--r-- | src/content/hint-key-producer.js | 33 | ||||
| -rw-r--r-- | src/content/hint.css | 7 | ||||
| -rw-r--r-- | src/content/hint.js | 43 | ||||
| -rw-r--r-- | src/content/index.js | 4 | ||||
| -rw-r--r-- | src/shared/actions.js | 4 | ||||
| -rw-r--r-- | test/content/follow.html | 9 | ||||
| -rw-r--r-- | test/content/follow.test.js | 25 | ||||
| -rw-r--r-- | test/content/hint-key-producer.test.js | 25 | ||||
| -rw-r--r-- | test/content/hint.html | 1 | ||||
| -rw-r--r-- | test/content/hint.test.js | 58 | 
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 +  }); +}); + + | 
