From 4c9d0433a6ac851e72d50d6fb0451baa9d35fd35 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 15 Oct 2017 09:02:09 +0900 Subject: make top-content component and frame-content component --- src/content/components/common/follow.js | 171 +++++++++++++++++++++++++++++ src/content/components/common/hint.css | 10 ++ src/content/components/common/hint.js | 36 ++++++ src/content/components/common/index.js | 46 ++++++++ src/content/components/common/input.js | 72 ++++++++++++ src/content/components/common/keymapper.js | 31 ++++++ src/content/components/content-input.js | 72 ------------ src/content/components/follow.js | 171 ----------------------------- src/content/components/frame-content.js | 16 +++ src/content/components/hint.css | 10 -- src/content/components/hint.js | 36 ------ src/content/components/keymapper.js | 31 ------ src/content/components/top-content.js | 28 +++++ src/content/index.js | 78 ++----------- 14 files changed, 417 insertions(+), 391 deletions(-) create mode 100644 src/content/components/common/follow.js create mode 100644 src/content/components/common/hint.css create mode 100644 src/content/components/common/hint.js create mode 100644 src/content/components/common/index.js create mode 100644 src/content/components/common/input.js create mode 100644 src/content/components/common/keymapper.js delete mode 100644 src/content/components/content-input.js delete mode 100644 src/content/components/follow.js create mode 100644 src/content/components/frame-content.js delete mode 100644 src/content/components/hint.css delete mode 100644 src/content/components/hint.js delete mode 100644 src/content/components/keymapper.js create mode 100644 src/content/components/top-content.js (limited to 'src') 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; + } +} diff --git a/src/content/components/content-input.js b/src/content/components/content-input.js deleted file mode 100644 index 3e70bbb..0000000 --- a/src/content/components/content-input.js +++ /dev/null @@ -1,72 +0,0 @@ -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 (['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/follow.js b/src/content/components/follow.js deleted file mode 100644 index 3f28cc2..0000000 --- a/src/content/components/follow.js +++ /dev/null @@ -1,171 +0,0 @@ -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/frame-content.js b/src/content/components/frame-content.js new file mode 100644 index 0000000..d2fb245 --- /dev/null +++ b/src/content/components/frame-content.js @@ -0,0 +1,16 @@ +import CommonComponent from './common'; + +export default class FrameContent { + + constructor(win, store) { + this.children = [new CommonComponent(win, store)]; + } + + update() { + this.children.forEach(c => c.update()); + } + + onMessage(message) { + this.children.forEach(c => c.onMessage(message)); + } +} diff --git a/src/content/components/hint.css b/src/content/components/hint.css deleted file mode 100644 index 119dd21..0000000 --- a/src/content/components/hint.css +++ /dev/null @@ -1,10 +0,0 @@ -.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/hint.js b/src/content/components/hint.js deleted file mode 100644 index cc46fd6..0000000 --- a/src/content/components/hint.js +++ /dev/null @@ -1,36 +0,0 @@ -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/keymapper.js b/src/content/components/keymapper.js deleted file mode 100644 index 655c3f2..0000000 --- a/src/content/components/keymapper.js +++ /dev/null @@ -1,31 +0,0 @@ -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; - } -} diff --git a/src/content/components/top-content.js b/src/content/components/top-content.js new file mode 100644 index 0000000..9b58947 --- /dev/null +++ b/src/content/components/top-content.js @@ -0,0 +1,28 @@ +import CommonComponent from './common'; +import * as consoleFrames from '../console-frames'; +import messages from 'shared/messages'; + +export default class TopContent { + + constructor(win, store) { + this.win = win; + this.children = [new CommonComponent(win, store)]; + + // TODO make component + consoleFrames.initialize(window.document); + } + + update() { + this.children.forEach(c => c.update()); + } + + onMessage(message) { + switch (message.type) { + case messages.CONSOLE_HIDE_COMMAND: + this.win.focus(); + consoleFrames.blur(window.document); + return Promise.resolve(); + } + this.children.forEach(c => c.onMessage(message)); + } +} diff --git a/src/content/index.js b/src/content/index.js index 4d1658e..589eb98 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -1,78 +1,14 @@ import './console-frame.scss'; -import * as consoleFrames from './console-frames'; -import * as inputActions from './actions/input'; import { createStore } from 'shared/store'; -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'; +import TopContentComponent from './components/top-content'; +import FrameContentComponent from './components/frame-content'; const store = createStore(reducers); -const followComponent = new FollowComponent(window, store); -const contentInputComponent = - new ContentInputComponent(window.document.body, store); -const keymapperComponent = new KeymapperComponent(store); -contentInputComponent.onKey((key, ctrl) => { - return followComponent.key(key, ctrl); -}); -contentInputComponent.onKey((key, ctrl) => { - return keymapperComponent.key(key, ctrl); -}); -store.subscribe(() => { - try { - followComponent.update(); - contentInputComponent.update(); - } catch (e) { - console.error(e); - } -}); -const reloadSettings = () => { - return browser.runtime.sendMessage({ - type: messages.SETTINGS_QUERY, - }).then((settings) => { - store.dispatch(inputActions.setKeymaps(settings.keymaps)); - }); -}; +let rootComponent = window.self === window.top + ? new TopContentComponent(window, store) + : new FrameContentComponent(window, store); -// TODO: the followin methods should be implemented in each top component and -// frame component -const initTopComponents = () => { - consoleFrames.initialize(window.document); - - browser.runtime.onMessage.addListener((action) => { - switch (action.type) { - case messages.CONSOLE_HIDE_COMMAND: - window.focus(); - consoleFrames.blur(window.document); - return Promise.resolve(); - case messages.SETTINGS_CHANGED: - return reloadSettings(); - default: - return Promise.resolve(); - } - }); -}; - -const initFrameConponents = () => { - browser.runtime.onMessage.addListener((action) => { - switch (action.type) { - case messages.CONSOLE_HIDE_COMMAND: - window.focus(); - return Promise.resolve(); - case messages.SETTINGS_CHANGED: - return reloadSettings(); - default: - return Promise.resolve(); - } - }); -}; - -if (window.self === window.top) { - initTopComponents(); -} else { - initFrameConponents(); -} - -reloadSettings(); +browser.runtime.onMessage.addListener(msg => rootComponent.onMessage(msg)); +rootComponent.update(); -- cgit v1.2.3