aboutsummaryrefslogtreecommitdiff
path: root/src/content
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2017-10-08 15:04:55 +0900
committerShin'ya Ueoka <ueokande@i-beam.org>2017-10-08 15:04:55 +0900
commit39fb5400370b818760dc7bcfe42e74b2512a840d (patch)
tree5191fc93c4cf1489c9ddd9d0003e30137a967018 /src/content
parentd886d7de290b6fee00c55c5487416048f3de4bf2 (diff)
separate content
Diffstat (limited to 'src/content')
-rw-r--r--src/content/actions/follow.js29
-rw-r--r--src/content/actions/index.js20
-rw-r--r--src/content/actions/input.js23
-rw-r--r--src/content/actions/operation.js43
-rw-r--r--src/content/components/content-input.js67
-rw-r--r--src/content/components/follow.js168
-rw-r--r--src/content/components/keymapper.js43
-rw-r--r--src/content/index.js8
-rw-r--r--src/content/reducers/follow.js32
-rw-r--r--src/content/reducers/index.js18
-rw-r--r--src/content/reducers/input.js20
11 files changed, 467 insertions, 4 deletions
diff --git a/src/content/actions/follow.js b/src/content/actions/follow.js
new file mode 100644
index 0000000..5a18dd5
--- /dev/null
+++ b/src/content/actions/follow.js
@@ -0,0 +1,29 @@
+import actions from 'content/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/content/actions/index.js b/src/content/actions/index.js
new file mode 100644
index 0000000..0b3749d
--- /dev/null
+++ b/src/content/actions/index.js
@@ -0,0 +1,20 @@
+export default {
+ // User input
+ INPUT_KEY_PRESS: 'input.key,press',
+ INPUT_CLEAR_KEYS: 'input.clear.keys',
+ INPUT_SET_KEYMAPS: 'input.set,keymaps',
+
+ // Completion
+ COMPLETION_SET_ITEMS: 'completion.set.items',
+ COMPLETION_SELECT_NEXT: 'completions.select.next',
+ COMPLETION_SELECT_PREV: 'completions.select.prev',
+
+ // 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/content/actions/input.js b/src/content/actions/input.js
new file mode 100644
index 0000000..cc4efac
--- /dev/null
+++ b/src/content/actions/input.js
@@ -0,0 +1,23 @@
+import actions from 'content/actions';
+
+const asKeymapChars = (key, ctrl) => {
+ if (ctrl) {
+ return '<C-' + key.toUpperCase() + '>';
+ }
+ return key;
+};
+
+const keyPress = (key, ctrl) => {
+ return {
+ type: actions.INPUT_KEY_PRESS,
+ key: asKeymapChars(key, ctrl),
+ };
+};
+
+const clearKeys = () => {
+ return {
+ type: actions.INPUT_CLEAR_KEYS
+ };
+};
+
+export { keyPress, clearKeys };
diff --git a/src/content/actions/operation.js b/src/content/actions/operation.js
new file mode 100644
index 0000000..d188a60
--- /dev/null
+++ b/src/content/actions/operation.js
@@ -0,0 +1,43 @@
+import operations from 'shared/operations';
+import messages from 'shared/messages';
+import * as scrolls from 'content/scrolls';
+import * as navigates from 'content/navigates';
+import * as followActions from 'content/actions/follow';
+
+const exec = (operation) => {
+ switch (operation.type) {
+ case operations.SCROLL_LINES:
+ return scrolls.scrollLines(window, operation.count);
+ case operations.SCROLL_PAGES:
+ return scrolls.scrollPages(window, operation.count);
+ case operations.SCROLL_TOP:
+ return scrolls.scrollTop(window);
+ case operations.SCROLL_BOTTOM:
+ return scrolls.scrollBottom(window);
+ case operations.SCROLL_HOME:
+ return scrolls.scrollLeft(window);
+ case operations.SCROLL_END:
+ return scrolls.scrollRight(window);
+ case operations.FOLLOW_START:
+ return followActions.enable(false);
+ case operations.NAVIGATE_HISTORY_PREV:
+ return navigates.historyPrev(window);
+ case operations.NAVIGATE_HISTORY_NEXT:
+ return navigates.historyNext(window);
+ case operations.NAVIGATE_LINK_PREV:
+ return navigates.linkPrev(window);
+ case operations.NAVIGATE_LINK_NEXT:
+ return navigates.linkNext(window);
+ case operations.NAVIGATE_PARENT:
+ return navigates.parent(window);
+ case operations.NAVIGATE_ROOT:
+ return navigates.root(window);
+ default:
+ browser.runtime.sendMessage({
+ type: messages.BACKGROUND_OPERATION,
+ operation,
+ });
+ }
+};
+
+export { exec };
diff --git a/src/content/components/content-input.js b/src/content/components/content-input.js
new file mode 100644
index 0000000..9568caf
--- /dev/null
+++ b/src/content/components/content-input.js
@@ -0,0 +1,67 @@
+export default class ContentInputComponent {
+ constructor(target) {
+ this.pressed = {};
+ this.onKeyListeners = [];
+
+ target.addEventListener('keypress', this.onKeyPress.bind(this));
+ target.addEventListener('keydown', this.onKeyDown.bind(this));
+ target.addEventListener('keyup', this.onKeyUp.bind(this));
+ }
+
+ update() {
+ }
+
+ onKey(cb) {
+ this.onKeyListeners.push(cb);
+ }
+
+ onKeyPress(e) {
+ if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') {
+ return;
+ }
+ this.pressed[e.key] = 'keypress';
+ this.capture(e);
+ }
+
+ onKeyDown(e) {
+ if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') {
+ return;
+ }
+ this.pressed[e.key] = 'keydown';
+ this.capture(e);
+ }
+
+ onKeyUp(e) {
+ delete this.pressed[e.key];
+ }
+
+ capture(e) {
+ if (this.fromInput(e)) {
+ if (e.key === 'Escape' && e.target.blur) {
+ e.target.blur();
+ }
+ return;
+ }
+ if (e.key === 'OS') {
+ return;
+ }
+
+ let stop = false;
+ for (let listener of this.onKeyListeners) {
+ stop = stop || listener(e.key, e.ctrlKey);
+ if (stop) {
+ break;
+ }
+ }
+ if (stop) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ fromInput(e) {
+ return e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLSelectElement;
+ }
+}
diff --git a/src/content/components/follow.js b/src/content/components/follow.js
new file mode 100644
index 0000000..c87424d
--- /dev/null
+++ b/src/content/components/follow.js
@@ -0,0 +1,168 @@
+import * as followActions from 'content/actions/follow';
+import messages from 'shared/messages';
+import Hint from 'content/hint';
+import HintKeyProducer from 'content/hint-key-producer';
+
+const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
+
+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 = {};
+ }
+
+ update() {
+ let prevState = this.state;
+ this.state = this.store.getState().follow;
+ if (!prevState.enabled && this.state.enabled) {
+ this.create();
+ } else if (prevState.enabled && !this.state.enabled) {
+ this.remove();
+ } else if (prevState.keys !== this.state.keys) {
+ this.updateHints();
+ }
+ }
+
+ key(key) {
+ if (!this.state.enabled) {
+ return false;
+ }
+
+ switch (key) {
+ case 'Enter':
+ this.activate(this.hintElements[this.state.keys].target);
+ return;
+ case 'Escape':
+ this.store.dispatch(followActions.disable());
+ return;
+ case 'Backspace':
+ case 'Delete':
+ this.store.dispatch(followActions.backspace());
+ break;
+ default:
+ if (DEFAULT_HINT_CHARSET.includes(key)) {
+ this.store.dispatch(followActions.keyPress(key));
+ }
+ break;
+ }
+ return true;
+ }
+
+ updateHints() {
+ let keys = this.state.keys;
+ let shown = Object.keys(this.hintElements).filter((key) => {
+ return key.startsWith(keys);
+ });
+ let hidden = Object.keys(this.hintElements).filter((key) => {
+ return !key.startsWith(keys);
+ });
+ if (shown.length === 0) {
+ this.remove();
+ return;
+ } else if (shown.length === 1) {
+ this.activate(this.hintElements[keys].target);
+ this.store.dispatch(followActions.disable());
+ }
+
+ 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 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/components/keymapper.js b/src/content/components/keymapper.js
new file mode 100644
index 0000000..8f2cead
--- /dev/null
+++ b/src/content/components/keymapper.js
@@ -0,0 +1,43 @@
+import * as inputActions from 'content/actions/input';
+import * as operationActions from 'content/actions/operation';
+
+export default class KeymapperComponent {
+ constructor(store) {
+ this.store = store;
+ }
+
+ update() {
+ }
+
+ key(key, ctrl) {
+ let keymaps = this.keymaps();
+ if (!keymaps) {
+ return;
+ }
+ this.store.dispatch(inputActions.keyPress(key, ctrl));
+
+ let input = this.store.getState().input;
+ let matched = Object.keys(keymaps).filter((keyStr) => {
+ return keyStr.startsWith(input.keys);
+ });
+ if (matched.length === 0) {
+ this.store.dispatch(inputActions.clearKeys());
+ return false;
+ } else if (matched.length > 1 ||
+ matched.length === 1 && input.keys !== matched[0]) {
+ return true;
+ }
+ let operation = keymaps[matched];
+ this.store.dispatch(operationActions.exec(operation));
+ this.store.dispatch(inputActions.clearKeys());
+ return true;
+ }
+
+ keymaps() {
+ let settings = this.store.getState().setting.settings;
+ if (!settings || !settings.json) {
+ return null;
+ }
+ return JSON.parse(settings.json).keymaps;
+ }
+}
diff --git a/src/content/index.js b/src/content/index.js
index edca510..00873cc 100644
--- a/src/content/index.js
+++ b/src/content/index.js
@@ -2,10 +2,10 @@ import './console-frame.scss';
import * as consoleFrames from './console-frames';
import * as settingActions from 'settings/actions/setting';
import { createStore } from 'store';
-import ContentInputComponent from 'components/content-input';
-import KeymapperComponent from 'components/keymapper';
-import FollowComponent from 'components/follow';
-import reducers from 'reducers';
+import ContentInputComponent from 'content/components/content-input';
+import KeymapperComponent from 'content/components/keymapper';
+import FollowComponent from 'content/components/follow';
+import reducers from 'content/reducers';
import messages from 'shared/messages';
const store = createStore(reducers);
diff --git a/src/content/reducers/follow.js b/src/content/reducers/follow.js
new file mode 100644
index 0000000..b7c0cf3
--- /dev/null
+++ b/src/content/reducers/follow.js
@@ -0,0 +1,32 @@
+import actions from 'content/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,
+ keys: '',
+ });
+ case actions.FOLLOW_DISABLE:
+ return Object.assign({}, state, {
+ enabled: false,
+ });
+ case actions.FOLLOW_KEY_PRESS:
+ return Object.assign({}, state, {
+ keys: state.keys + action.key,
+ });
+ case actions.FOLLOW_BACKSPACE:
+ return Object.assign({}, state, {
+ keys: state.keys.slice(0, -1),
+ });
+ default:
+ return state;
+ }
+}
diff --git a/src/content/reducers/index.js b/src/content/reducers/index.js
new file mode 100644
index 0000000..a62217f
--- /dev/null
+++ b/src/content/reducers/index.js
@@ -0,0 +1,18 @@
+import settingReducer from 'settings/reducers/setting';
+import inputReducer from './input';
+import followReducer from './follow';
+
+// Make setting reducer instead of re-use
+const defaultState = {
+ input: inputReducer(undefined, {}),
+ setting: settingReducer(undefined, {}),
+ follow: followReducer(undefined, {}),
+};
+
+export default function reducer(state = defaultState, action = {}) {
+ return Object.assign({}, state, {
+ input: inputReducer(state.input, action),
+ setting: settingReducer(state.setting, action),
+ follow: followReducer(state.follow, action),
+ });
+}
diff --git a/src/content/reducers/input.js b/src/content/reducers/input.js
new file mode 100644
index 0000000..802020f
--- /dev/null
+++ b/src/content/reducers/input.js
@@ -0,0 +1,20 @@
+import actions from 'content/actions';
+
+const defaultState = {
+ keys: '',
+};
+
+export default function reducer(state = defaultState, action = {}) {
+ switch (action.type) {
+ case actions.INPUT_KEY_PRESS:
+ return Object.assign({}, state, {
+ keys: state.keys + action.key
+ });
+ case actions.INPUT_CLEAR_KEYS:
+ return Object.assign({}, state, {
+ keys: '',
+ });
+ default:
+ return state;
+ }
+}