aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2017-10-02 21:35:52 +0900
committerShin'ya Ueoka <ueokande@i-beam.org>2017-10-02 21:38:23 +0900
commit0a7ae631cd72100abdc26b84b06006bb5b166cba (patch)
tree3bef6ca26a12e3b3cbedb3d1dbb9d0a39a7fd466
parent6f857e2c81b2d31a86d39c02b010345d3ff23a38 (diff)
follow as redux
-rw-r--r--src/actions/follow.js29
-rw-r--r--src/actions/index.js6
-rw-r--r--src/components/follow.js210
-rw-r--r--src/content/follow.js149
-rw-r--r--src/content/index.js68
-rw-r--r--src/reducers/follow.js31
-rw-r--r--test/components/follow.html (renamed from test/content/follow.html)0
-rw-r--r--test/components/follow.test.js (renamed from test/content/follow.test.js)12
8 files changed, 297 insertions, 208 deletions
diff --git a/src/actions/follow.js b/src/actions/follow.js
new file mode 100644
index 0000000..7ab689e
--- /dev/null
+++ b/src/actions/follow.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 };
diff --git a/src/actions/index.js b/src/actions/index.js
index 63c36d2..4e8d4a7 100644
--- a/src/actions/index.js
+++ b/src/actions/index.js
@@ -17,4 +17,10 @@ export default {
// 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',
};
diff --git a/src/components/follow.js b/src/components/follow.js
new file mode 100644
index 0000000..4fe4c58
--- /dev/null
+++ b/src/components/follow.js
@@ -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;
+ }
+}
diff --git a/src/content/follow.js b/src/content/follow.js
deleted file mode 100644
index b1d2f5c..0000000
--- a/src/content/follow.js
+++ /dev/null
@@ -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;
- }
-}
diff --git a/src/content/index.js b/src/content/index.js
index 2e64af2..0dbc8c1 100644
--- a/src/content/index.js
+++ b/src/content/index.js
@@ -2,62 +2,24 @@ import './console-frame.scss';
import * as consoleFrames from './console-frames';
import * as scrolls from '../content/scrolls';
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 messages from './messages';
-consoleFrames.initialize(window.document);
-
-const startFollows = (newTab) => {
- let follow = new Follow(window.document);
- follow.onActivated((element) => {
- switch (element.tagName.toLowerCase()) {
- case 'a':
- if (newTab) {
- // getAttribute() to avoid to resolve absolute path
- let href = element.getAttribute('href');
+const followStore = store.createStore(followReducer);
+const followComponent = new FollowComponent(window.document.body, followStore);
+followStore.subscribe(() => {
+ try {
+ followComponent.update();
+ } catch (e) {
+ console.error(e);
+ }
+});
- // 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) {
- 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();
- }
- });
-};
+consoleFrames.initialize(window.document);
window.addEventListener('keypress', (e) => {
if (e.target instanceof HTMLInputElement ||
@@ -90,7 +52,7 @@ const execOperation = (operation) => {
case operations.SCROLL_END:
return scrolls.scrollRight(window);
case operations.FOLLOW_START:
- return startFollows(operation.newTab);
+ return followStore.dispatch(followActions.enable(false));
case operations.NAVIGATE_HISTORY_PREV:
return navigates.historyPrev(window);
case operations.NAVIGATE_HISTORY_NEXT:
diff --git a/src/reducers/follow.js b/src/reducers/follow.js
new file mode 100644
index 0000000..136b367
--- /dev/null
+++ b/src/reducers/follow.js
@@ -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;
+ }
+}
diff --git a/test/content/follow.html b/test/components/follow.html
index 6bd8f87..6bd8f87 100644
--- a/test/content/follow.html
+++ b/test/components/follow.html
diff --git a/test/content/follow.test.js b/test/components/follow.test.js
index fd4f0bc..f2f870e 100644
--- a/test/content/follow.test.js
+++ b/test/components/follow.test.js
@@ -1,24 +1,24 @@
import { expect } from "chai";
-import Follow from '../../src/content/follow';
+import FollowComponent from '../../src/components/follow';
-describe('Follow class', () => {
+describe('FollowComponent', () => {
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('');
+ expect(FollowComponent.codeChars(chars)).to.equal('01ab');
+ expect(FollowComponent.codeChars([])).to.be.equal('');
});
});
describe('#getTargetElements', () => {
beforeEach(() => {
- document.body.innerHTML = __html__['test/content/follow.html'];
+ document.body.innerHTML = __html__['test/components/follow.html'];
});
it('returns visible links', () => {
- let links = Follow.getTargetElements(window.document);
+ let links = FollowComponent.getTargetElements(window.document);
expect(links).to.have.lengthOf(1);
});
});