Merge branch 'more-redux'

jh-changes
Shin'ya Ueoka 7 years ago
commit b5e52a75d7
  1. 29
      src/actions/follow.js
  2. 6
      src/actions/index.js
  3. 210
      src/components/follow.js
  4. 149
      src/content/follow.js
  5. 68
      src/content/index.js
  6. 31
      src/reducers/follow.js
  7. 35
      test/actions/follow.test.js
  8. 0
      test/components/follow.html
  9. 12
      test/components/follow.test.js
  10. 47
      test/reducers/follow.test.js

@ -0,0 +1,29 @@
import actions from '../actions';
const enable = (newTab) => {
return {
type: actions.FOLLOW_ENABLE,
newTab,
};
};
const disable = () => {
return {
type: actions.FOLLOW_DISABLE,
};
};
const keyPress = (key) => {
return {
type: actions.FOLLOW_KEY_PRESS,
key: key
};
};
const backspace = () => {
return {
type: actions.FOLLOW_BACKSPACE,
};
};
export { enable, disable, keyPress, backspace };

@ -17,4 +17,10 @@ export default {
// Settings // Settings
SETTING_SET_SETTINGS: 'setting.set.settings', SETTING_SET_SETTINGS: 'setting.set.settings',
// Follow
FOLLOW_ENABLE: 'follow.enable',
FOLLOW_DISABLE: 'follow.disable',
FOLLOW_KEY_PRESS: 'follow.key.press',
FOLLOW_BACKSPACE: 'follow.backspace',
}; };

@ -0,0 +1,210 @@
import * as followActions from '../actions/follow';
import messages from '../content/messages';
import Hint from '../content/hint';
import HintKeyProducer from '../content/hint-key-producer';
const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
const availableKey = (keyCode) => {
return (
KeyboardEvent.DOM_VK_0 <= keyCode && keyCode <= KeyboardEvent.DOM_VK_9 ||
KeyboardEvent.DOM_VK_A <= keyCode && keyCode <= KeyboardEvent.DOM_VK_Z
);
};
const isNumericKey = (code) => {
return KeyboardEvent.DOM_VK_0 <= code && code <= KeyboardEvent.DOM_VK_9;
};
const isAlphabeticKey = (code) => {
return KeyboardEvent.DOM_VK_A <= code && code <= KeyboardEvent.DOM_VK_Z;
};
const inWindow = (window, element) => {
let {
top, left, bottom, right
} = element.getBoundingClientRect();
return (
top >= 0 && left >= 0 &&
bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
right <= (window.innerWidth || document.documentElement.clientWidth)
);
};
export default class FollowComponent {
constructor(wrapper, store) {
this.wrapper = wrapper;
this.store = store;
this.hintElements = {};
this.state = {};
let doc = wrapper.ownerDocument;
doc.addEventListener('keydown', this.onKeyDown.bind(this));
}
update() {
let prevState = this.state;
this.state = this.store.getState();
if (!prevState.enabled && this.state.enabled) {
this.create();
} else if (prevState.enabled && !this.state.enabled) {
this.remove();
} else if (JSON.stringify(prevState.keys) !==
JSON.stringify(this.state.keys)) {
this.updateHints();
}
}
onKeyDown(e) {
if (!this.state.enabled) {
return;
}
let { keyCode } = e;
switch (keyCode) {
case KeyboardEvent.DOM_VK_ENTER:
case KeyboardEvent.DOM_VK_RETURN:
this.activate(this.hintElements[
FollowComponent.codeChars(this.state.keys)].target);
return;
case KeyboardEvent.DOM_VK_ESCAPE:
this.store.dispatch(followActions.disable());
return;
case KeyboardEvent.DOM_VK_BACK_SPACE:
case KeyboardEvent.DOM_VK_DELETE:
this.store.dispatch(followActions.backspace());
break;
default:
if (availableKey(keyCode)) {
this.store.dispatch(followActions.keyPress(keyCode));
}
break;
}
e.stopPropagation();
e.preventDefault();
}
updateHints() {
let chars = FollowComponent.codeChars(this.state.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.activate(this.hintElements[chars].target);
this.remove();
}
shown.forEach((key) => {
this.hintElements[key].show();
});
hidden.forEach((key) => {
this.hintElements[key].hide();
});
}
activate(element) {
switch (element.tagName.toLowerCase()) {
case 'a':
if (this.state.newTab) {
// getAttribute() to avoid to resolve absolute path
let href = element.getAttribute('href');
// eslint-disable-next-line no-script-url
if (!href || href === '#' || href.startsWith('javascript:')) {
return;
}
return browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: element.href,
newTab: this.state.newTab,
});
}
if (element.href.startsWith('http://') ||
element.href.startsWith('https://') ||
element.href.startsWith('ftp://')) {
return browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: element.href,
newTab: this.state.newTab,
});
}
return element.click();
case 'input':
switch (element.type) {
case 'file':
case 'checkbox':
case 'radio':
case 'submit':
case 'reset':
case 'button':
case 'image':
case 'color':
return element.click();
default:
return element.focus();
}
case 'textarea':
return element.focus();
case 'button':
return element.click();
}
}
create() {
let doc = this.wrapper.ownerDocument;
let elements = FollowComponent.getTargetElements(doc);
let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET);
let hintElements = {};
Array.prototype.forEach.call(elements, (ele) => {
let keys = producer.produce();
let hint = new Hint(ele, keys);
hintElements[keys] = hint;
});
this.hintElements = hintElements;
}
remove() {
let hintElements = this.hintElements;
Object.keys(this.hintElements).forEach((key) => {
hintElements[key].remove();
});
}
static codeChars(codes) {
const CHARCODE_ZERO = '0'.charCodeAt(0);
const CHARCODE_A = 'a'.charCodeAt(0);
let chars = '';
for (let code of codes) {
if (isNumericKey(code)) {
chars += String.fromCharCode(
code - KeyboardEvent.DOM_VK_0 + CHARCODE_ZERO);
} else if (isAlphabeticKey(code)) {
chars += String.fromCharCode(
code - KeyboardEvent.DOM_VK_A + CHARCODE_A);
}
}
return chars;
}
static getTargetElements(doc) {
let all = doc.querySelectorAll('a,button,input,textarea');
let filtered = Array.prototype.filter.call(all, (element) => {
let style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.type !== 'hidden' &&
element.offsetHeight > 0 &&
inWindow(window, element);
});
return filtered;
}
}

@ -1,149 +0,0 @@
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 = [];
this.onActivatedCallbacks = [];
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;
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.activate(this.hintElements[chars].target);
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();
}
e.stopPropagation();
e.preventDefault();
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.activate(this.hintElements[chars].target);
}
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();
});
}
activate(element) {
this.onActivatedCallbacks.forEach(f => f(element));
}
onActivated(f) {
this.onActivatedCallbacks.push(f);
}
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 isNumericKey(code) {
return KeyboardEvent.DOM_VK_0 <= code && code <= KeyboardEvent.DOM_VK_9;
}
static isAlphabeticKey(code) {
return KeyboardEvent.DOM_VK_A <= code && code <= 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 (Follow.isNumericKey(code)) {
chars += String.fromCharCode(
code - KeyboardEvent.DOM_VK_0 + CHARCODE_ZERO);
} else if (Follow.isAlphabeticKey(code)) {
chars += String.fromCharCode(
code - KeyboardEvent.DOM_VK_A + CHARCODE_A);
}
}
return chars;
}
static inWindow(window, element) {
let {
top, left, bottom, right
} = element.getBoundingClientRect();
return (
top >= 0 && left >= 0 &&
bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
static getTargetElements(doc) {
let all = doc.querySelectorAll('a,button,input,textarea');
let filtered = Array.prototype.filter.call(all, (element) => {
let style = window.getComputedStyle(element);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
element.type !== 'hidden' &&
element.offsetHeight > 0 &&
Follow.inWindow(window, element);
});
return filtered;
}
}

@ -2,62 +2,24 @@ import './console-frame.scss';
import * as consoleFrames from './console-frames'; import * as consoleFrames from './console-frames';
import * as scrolls from '../content/scrolls'; import * as scrolls from '../content/scrolls';
import * as navigates from '../content/navigates'; import * as navigates from '../content/navigates';
import Follow from '../content/follow'; import * as followActions from '../actions/follow';
import * as store from '../store';
import FollowComponent from '../components/follow';
import followReducer from '../reducers/follow';
import operations from '../operations'; import operations from '../operations';
import messages from './messages'; import messages from './messages';
consoleFrames.initialize(window.document); const followStore = store.createStore(followReducer);
const followComponent = new FollowComponent(window.document.body, followStore);
const startFollows = (newTab) => { followStore.subscribe(() => {
let follow = new Follow(window.document); try {
follow.onActivated((element) => { followComponent.update();
switch (element.tagName.toLowerCase()) { } catch (e) {
case 'a': console.error(e);
if (newTab) {
// getAttribute() to avoid to resolve absolute path
let href = element.getAttribute('href');
// eslint-disable-next-line no-script-url
if (!href || href === '#' || href.startsWith('javascript:')) {
return;
}
return browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: element.href,
newTab
});
}
if (element.href.startsWith('http://') ||
element.href.startsWith('https://') ||
element.href.startsWith('ftp://')) {
return browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: element.href,
newTab
});
} }
return element.click(); });
case 'input':
switch (element.type) { consoleFrames.initialize(window.document);
case 'file':
case 'checkbox':
case 'radio':
case 'submit':
case 'reset':
case 'button':
case 'image':
case 'color':
return element.click();
default:
return element.focus();
}
case 'textarea':
return element.focus();
case 'button':
return element.click();
}
});
};
window.addEventListener('keypress', (e) => { window.addEventListener('keypress', (e) => {
if (e.target instanceof HTMLInputElement || if (e.target instanceof HTMLInputElement ||
@ -90,7 +52,7 @@ const execOperation = (operation) => {
case operations.SCROLL_END: case operations.SCROLL_END:
return scrolls.scrollRight(window); return scrolls.scrollRight(window);
case operations.FOLLOW_START: case operations.FOLLOW_START:
return startFollows(operation.newTab); return followStore.dispatch(followActions.enable(false));
case operations.NAVIGATE_HISTORY_PREV: case operations.NAVIGATE_HISTORY_PREV:
return navigates.historyPrev(window); return navigates.historyPrev(window);
case operations.NAVIGATE_HISTORY_NEXT: case operations.NAVIGATE_HISTORY_NEXT:

@ -0,0 +1,31 @@
import actions from '../actions';
const defaultState = {
enabled: false,
newTab: false,
keys: [],
};
export default function reducer(state = defaultState, action = {}) {
switch (action.type) {
case actions.FOLLOW_ENABLE:
return Object.assign({}, state, {
enabled: true,
newTab: action.newTab,
});
case actions.FOLLOW_DISABLE:
return Object.assign({}, state, {
enabled: false,
});
case actions.FOLLOW_KEY_PRESS:
return Object.assign({}, state, {
keys: state.keys.concat([action.key]),
});
case actions.FOLLOW_BACKSPACE:
return Object.assign({}, state, {
keys: state.keys.slice(0, -1),
});
default:
return state;
}
}

@ -0,0 +1,35 @@
import { expect } from "chai";
import actions from '../../src/actions';
import * as followActions from '../../src/actions/follow';
describe('follow actions', () => {
describe('enable', () => {
it('creates FOLLOW_ENABLE action', () => {
let action = followActions.enable(true);
expect(action.type).to.equal(actions.FOLLOW_ENABLE);
expect(action.newTab).to.equal(true);
});
});
describe('disable', () => {
it('creates FOLLOW_DISABLE action', () => {
let action = followActions.disable(true);
expect(action.type).to.equal(actions.FOLLOW_DISABLE);
});
});
describe('keyPress', () => {
it('creates FOLLOW_KEY_PRESS action', () => {
let action = followActions.keyPress(100);
expect(action.type).to.equal(actions.FOLLOW_KEY_PRESS);
expect(action.key).to.equal(100);
});
});
describe('backspace', () => {
it('creates FOLLOW_BACKSPACE action', () => {
let action = followActions.backspace(100);
expect(action.type).to.equal(actions.FOLLOW_BACKSPACE);
});
});
});

@ -1,24 +1,24 @@
import { expect } from "chai"; import { expect } from "chai";
import Follow from '../../src/content/follow'; import FollowComponent from '../../src/components/follow';
describe('Follow class', () => { describe('FollowComponent', () => {
describe('#codeChars', () => { describe('#codeChars', () => {
it('returns a string for key codes', () => { it('returns a string for key codes', () => {
let chars = [ let chars = [
KeyboardEvent.DOM_VK_0, KeyboardEvent.DOM_VK_1, KeyboardEvent.DOM_VK_0, KeyboardEvent.DOM_VK_1,
KeyboardEvent.DOM_VK_A, KeyboardEvent.DOM_VK_B]; KeyboardEvent.DOM_VK_A, KeyboardEvent.DOM_VK_B];
expect(Follow.codeChars(chars)).to.equal('01ab'); expect(FollowComponent.codeChars(chars)).to.equal('01ab');
expect(Follow.codeChars([])).to.be.equal(''); expect(FollowComponent.codeChars([])).to.be.equal('');
}); });
}); });
describe('#getTargetElements', () => { describe('#getTargetElements', () => {
beforeEach(() => { beforeEach(() => {
document.body.innerHTML = __html__['test/content/follow.html']; document.body.innerHTML = __html__['test/components/follow.html'];
}); });
it('returns visible links', () => { it('returns visible links', () => {
let links = Follow.getTargetElements(window.document); let links = FollowComponent.getTargetElements(window.document);
expect(links).to.have.lengthOf(1); expect(links).to.have.lengthOf(1);
}); });
}); });

@ -0,0 +1,47 @@
import { expect } from "chai";
import actions from '../../src/actions';
import followReducer from '../../src/reducers/follow';
describe('follow reducer', () => {
it ('returns the initial state', () => {
let state = followReducer(undefined, {});
expect(state).to.have.property('enabled', false);
expect(state).to.have.property('newTab');
expect(state).to.have.deep.property('keys', []);
});
it ('returns next state for FOLLOW_ENABLE', () => {
let action = { type: actions.FOLLOW_ENABLE, newTab: true };
let state = followReducer({ enabled: false, newTab: false }, action);
expect(state).to.have.property('enabled', true);
expect(state).to.have.property('newTab', true);
});
it ('returns next state for FOLLOW_DISABLE', () => {
let action = { type: actions.FOLLOW_DISABLE };
let state = followReducer({ enabled: true }, action);
expect(state).to.have.property('enabled', false);
});
it ('returns next state for FOLLOW_KEY_PRESS', () => {
let action = { type: actions.FOLLOW_KEY_PRESS, key: 100};
let state = followReducer({ keys: [] }, action);
expect(state).to.have.deep.property('keys', [100]);
action = { type: actions.FOLLOW_KEY_PRESS, key: 200};
state = followReducer(state, action);
expect(state).to.have.deep.property('keys', [100, 200]);
});
it ('returns next state for FOLLOW_BACKSPACE', () => {
let action = { type: actions.FOLLOW_BACKSPACE };
let state = followReducer({ keys: [100, 200] }, action);
expect(state).to.have.deep.property('keys', [100]);
state = followReducer(state, action);
expect(state).to.have.deep.property('keys', []);
state = followReducer(state, action);
expect(state).to.have.deep.property('keys', []);
});
});