diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2017-10-15 09:02:09 +0900 |
---|---|---|
committer | Shin'ya Ueoka <ueokande@i-beam.org> | 2017-10-15 09:04:00 +0900 |
commit | 4c9d0433a6ac851e72d50d6fb0451baa9d35fd35 (patch) | |
tree | 5b831137fe327e94fd73339e2b395bfe3e98961c /src/content/components/common | |
parent | 042aa94936c9114f0a0fd05fb0a91df8f5565ecd (diff) |
make top-content component and frame-content component
Diffstat (limited to 'src/content/components/common')
-rw-r--r-- | src/content/components/common/follow.js | 171 | ||||
-rw-r--r-- | src/content/components/common/hint.css | 10 | ||||
-rw-r--r-- | src/content/components/common/hint.js | 36 | ||||
-rw-r--r-- | src/content/components/common/index.js | 46 | ||||
-rw-r--r-- | src/content/components/common/input.js | 72 | ||||
-rw-r--r-- | src/content/components/common/keymapper.js | 31 |
6 files changed, 366 insertions, 0 deletions
diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js new file mode 100644 index 0000000..3f28cc2 --- /dev/null +++ b/src/content/components/common/follow.js @@ -0,0 +1,171 @@ +import * as followActions from 'content/actions/follow'; +import messages from 'shared/messages'; +import Hint from './hint'; +import HintKeyProducer from 'content/hint-key-producer'; + +const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; +const TARGET_SELECTOR = [ + 'a', 'button', 'input', 'textarea', + '[contenteditable=true]', '[contenteditable=""]' +].join(','); + +const inWindow = (win, element) => { + let { + top, left, bottom, right + } = element.getBoundingClientRect(); + let doc = win.doc; + return ( + top >= 0 && left >= 0 && + bottom <= (win.innerHeight || doc.documentElement.clientHeight) && + right <= (win.innerWidth || doc.documentElement.clientWidth) + ); +}; + +export default class FollowComponent { + constructor(win, store) { + this.win = win; + 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(); + }); + } + + openLink(element) { + if (!this.state.newTab) { + element.click(); + return; + } + + let href = element.getAttribute('href'); + + // eslint-disable-next-line no-script-url + if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) { + return; + } + return browser.runtime.sendMessage({ + type: messages.OPEN_URL, + url: element.href, + newTab: this.state.newTab, + }); + } + + activate(element) { + switch (element.tagName.toLowerCase()) { + case 'a': + return this.openLink(element, this.state.newTab); + 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(); + default: + // it may contenteditable + return element.focus(); + } + } + + create() { + let elements = FollowComponent.getTargetElements(this.win); + 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(win) { + let all = win.document.querySelectorAll(TARGET_SELECTOR); + let filtered = Array.prototype.filter.call(all, (element) => { + let style = win.getComputedStyle(element); + return style.display !== 'none' && + style.visibility !== 'hidden' && + element.type !== 'hidden' && + element.offsetHeight > 0 && + inWindow(win, element); + }); + return filtered; + } +} diff --git a/src/content/components/common/hint.css b/src/content/components/common/hint.css new file mode 100644 index 0000000..119dd21 --- /dev/null +++ b/src/content/components/common/hint.css @@ -0,0 +1,10 @@ +.vimvixen-hint { + background-color: yellow; + border: 1px solid gold; + font-weight: bold; + position: absolute; + text-transform: uppercase; + z-index: 100000; + font-size: 12px; + color: black; +} diff --git a/src/content/components/common/hint.js b/src/content/components/common/hint.js new file mode 100644 index 0000000..cc46fd6 --- /dev/null +++ b/src/content/components/common/hint.js @@ -0,0 +1,36 @@ +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, scrollY } = window; + + 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(); + } +} diff --git a/src/content/components/common/index.js b/src/content/components/common/index.js new file mode 100644 index 0000000..7673134 --- /dev/null +++ b/src/content/components/common/index.js @@ -0,0 +1,46 @@ +import InputComponent from './input'; +import KeymapperComponent from './keymapper'; +import FollowComponent from './follow'; +import * as inputActions from 'content/actions/input'; +import messages from 'shared/messages'; + +export default class Common { + constructor(win, store) { + const follow = new FollowComponent(win, store); + const input = new InputComponent(win.document.body, store); + const keymapper = new KeymapperComponent(store); + + input.onKey((key, ctrl) => { + follow.key(key, ctrl); + keymapper.key(key, ctrl); + }); + + this.store = store; + this.children = [ + follow, + input, + keymapper, + ]; + + this.reloadSettings(); + } + + update() { + this.children.forEach(c => c.update()); + } + + onMessage(message) { + switch (message) { + case messages.SETTINGS_CHANGED: + this.reloadSettings(); + } + } + + reloadSettings() { + browser.runtime.sendMessage({ + type: messages.SETTINGS_QUERY, + }).then((settings) => { + this.store.dispatch(inputActions.setKeymaps(settings.keymaps)); + }); + } +} diff --git a/src/content/components/common/input.js b/src/content/components/common/input.js new file mode 100644 index 0000000..df09894 --- /dev/null +++ b/src/content/components/common/input.js @@ -0,0 +1,72 @@ +export default class InputComponent { + 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 (['Shift', 'Control', 'Alt', 'OS'].includes(e.key)) { + // pressing only meta key is ignored + 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 || + e.target instanceof HTMLElement && + e.target.hasAttribute('contenteditable') && ( + e.target.getAttribute('contenteditable').toLowerCase() === 'true' || + e.target.getAttribute('contenteditable').toLowerCase() === ''); + } +} diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js new file mode 100644 index 0000000..655c3f2 --- /dev/null +++ b/src/content/components/common/keymapper.js @@ -0,0 +1,31 @@ +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) { + this.store.dispatch(inputActions.keyPress(key, ctrl)); + + let input = this.store.getState().input; + let matched = Object.keys(input.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 = input.keymaps[matched]; + this.store.dispatch(operationActions.exec(operation)); + this.store.dispatch(inputActions.clearKeys()); + return true; + } +} |