diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2017-10-08 15:04:55 +0900 |
---|---|---|
committer | Shin'ya Ueoka <ueokande@i-beam.org> | 2017-10-08 15:04:55 +0900 |
commit | 39fb5400370b818760dc7bcfe42e74b2512a840d (patch) | |
tree | 5191fc93c4cf1489c9ddd9d0003e30137a967018 /src/content | |
parent | d886d7de290b6fee00c55c5487416048f3de4bf2 (diff) |
separate content
Diffstat (limited to 'src/content')
-rw-r--r-- | src/content/actions/follow.js | 29 | ||||
-rw-r--r-- | src/content/actions/index.js | 20 | ||||
-rw-r--r-- | src/content/actions/input.js | 23 | ||||
-rw-r--r-- | src/content/actions/operation.js | 43 | ||||
-rw-r--r-- | src/content/components/content-input.js | 67 | ||||
-rw-r--r-- | src/content/components/follow.js | 168 | ||||
-rw-r--r-- | src/content/components/keymapper.js | 43 | ||||
-rw-r--r-- | src/content/index.js | 8 | ||||
-rw-r--r-- | src/content/reducers/follow.js | 32 | ||||
-rw-r--r-- | src/content/reducers/index.js | 18 | ||||
-rw-r--r-- | src/content/reducers/input.js | 20 |
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; + } +} |