From 890f84c34382253d6c178a5a09149832d145c60f Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 14 Oct 2017 06:40:49 +0900 Subject: move hint component --- src/content/components/follow.js | 2 +- src/content/components/hint.css | 10 ++++++++++ src/content/components/hint.js | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/content/components/hint.css create mode 100644 src/content/components/hint.js (limited to 'src/content/components') diff --git a/src/content/components/follow.js b/src/content/components/follow.js index eb453a5..119a493 100644 --- a/src/content/components/follow.js +++ b/src/content/components/follow.js @@ -1,6 +1,6 @@ import * as followActions from 'content/actions/follow'; import messages from 'shared/messages'; -import Hint from 'content/hint'; +import Hint from './hint'; import HintKeyProducer from 'content/hint-key-producer'; const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; diff --git a/src/content/components/hint.css b/src/content/components/hint.css new file mode 100644 index 0000000..119dd21 --- /dev/null +++ b/src/content/components/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/hint.js b/src/content/components/hint.js new file mode 100644 index 0000000..cc46fd6 --- /dev/null +++ b/src/content/components/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(); + } +} -- cgit v1.2.3 From bebf8e23275156d39decbc974bcc05fa1d977d26 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 14 Oct 2017 06:46:31 +0900 Subject: get hints from window --- src/content/components/follow.js | 22 +++++++++++----------- src/content/index.js | 2 +- test/content/components/follow.test.js | 2 +- 3 files changed, 13 insertions(+), 13 deletions(-) (limited to 'src/content/components') diff --git a/src/content/components/follow.js b/src/content/components/follow.js index 119a493..3307893 100644 --- a/src/content/components/follow.js +++ b/src/content/components/follow.js @@ -9,20 +9,21 @@ const TARGET_SELECTOR = [ '[contenteditable=true]', '[contenteditable=""]' ].join(','); -const inWindow = (window, element) => { +const inWindow = (win, element) => { let { top, left, bottom, right } = element.getBoundingClientRect(); + let doc = win.doc; return ( top >= 0 && left >= 0 && - bottom <= (window.innerHeight || document.documentElement.clientHeight) && - right <= (window.innerWidth || document.documentElement.clientWidth) + bottom <= (win.innerHeight || doc.documentElement.clientHeight) && + right <= (win.innerWidth || doc.documentElement.clientWidth) ); }; export default class FollowComponent { - constructor(wrapper, store) { - this.wrapper = wrapper; + constructor(win, store) { + this.win = win; this.store = store; this.hintElements = {}; this.state = {}; @@ -141,8 +142,7 @@ export default class FollowComponent { } create() { - let doc = this.wrapper.ownerDocument; - let elements = FollowComponent.getTargetElements(doc); + let elements = FollowComponent.getTargetElements(this.win); let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); let hintElements = {}; Array.prototype.forEach.call(elements, (ele) => { @@ -160,15 +160,15 @@ export default class FollowComponent { }); } - static getTargetElements(doc) { - let all = doc.querySelectorAll(TARGET_SELECTOR); + static getTargetElements(win) { + let all = win.document.querySelectorAll(TARGET_SELECTOR); let filtered = Array.prototype.filter.call(all, (element) => { - let style = window.getComputedStyle(element); + let style = win.getComputedStyle(element); return style.display !== 'none' && style.visibility !== 'hidden' && element.type !== 'hidden' && element.offsetHeight > 0 && - inWindow(window, element); + inWindow(win, element); }); return filtered; } diff --git a/src/content/index.js b/src/content/index.js index 64d86bb..65be89f 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -9,7 +9,7 @@ import reducers from 'content/reducers'; import messages from 'shared/messages'; const store = createStore(reducers); -const followComponent = new FollowComponent(window.document.body, store); +const followComponent = new FollowComponent(window, store); const contentInputComponent = new ContentInputComponent(window.document.body, store); const keymapperComponent = new KeymapperComponent(store); diff --git a/test/content/components/follow.test.js b/test/content/components/follow.test.js index 5c3e1d5..c8ae58b 100644 --- a/test/content/components/follow.test.js +++ b/test/content/components/follow.test.js @@ -8,7 +8,7 @@ describe('FollowComponent', () => { }); it('returns visible links', () => { - let targets = FollowComponent.getTargetElements(window.document); + let targets = FollowComponent.getTargetElements(window); expect(targets).to.have.lengthOf(3); let ids = Array.prototype.map.call(targets, (e) => e.id); -- cgit v1.2.3 From fc90f78e26dcf9531ec3702f99a3a45fd8cd4a18 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 14 Oct 2017 13:18:46 +0900 Subject: load content script all_frames and click link --- manifest.json | 1 + src/content/components/follow.js | 44 ++++++++++++++++++---------------------- 2 files changed, 21 insertions(+), 24 deletions(-) (limited to 'src/content/components') diff --git a/manifest.json b/manifest.json index 369c5a2..19f4974 100644 --- a/manifest.json +++ b/manifest.json @@ -14,6 +14,7 @@ }, "content_scripts": [ { + "all_frames": true, "matches": [ "http://*/*", "https://*/*" ], "js": [ "build/content.js" ] } diff --git a/src/content/components/follow.js b/src/content/components/follow.js index 3307893..3f28cc2 100644 --- a/src/content/components/follow.js +++ b/src/content/components/follow.js @@ -90,33 +90,29 @@ export default class FollowComponent { }); } + 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': - 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(); + return this.openLink(element, this.state.newTab); case 'input': switch (element.type) { case 'file': -- cgit v1.2.3 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 ++---------- test/content/components/common/follow.html | 12 ++ test/content/components/common/follow.test.js | 18 +++ test/content/components/common/hint.html | 1 + test/content/components/common/hint.test.js | 58 +++++++++ test/content/components/follow.html | 12 -- test/content/components/follow.test.js | 18 --- test/content/components/hint.html | 1 - test/content/components/hint.test.js | 58 --------- 22 files changed, 506 insertions(+), 480 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 create mode 100644 test/content/components/common/follow.html create mode 100644 test/content/components/common/follow.test.js create mode 100644 test/content/components/common/hint.html create mode 100644 test/content/components/common/hint.test.js delete mode 100644 test/content/components/follow.html delete mode 100644 test/content/components/follow.test.js delete mode 100644 test/content/components/hint.html delete mode 100644 test/content/components/hint.test.js (limited to 'src/content/components') 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(); diff --git a/test/content/components/common/follow.html b/test/content/components/common/follow.html new file mode 100644 index 0000000..eb0decd --- /dev/null +++ b/test/content/components/common/follow.html @@ -0,0 +1,12 @@ + + + + link + invisible 1 + invisible 2 + not link +
link
+
link
+
link
+ + diff --git a/test/content/components/common/follow.test.js b/test/content/components/common/follow.test.js new file mode 100644 index 0000000..97bd1d2 --- /dev/null +++ b/test/content/components/common/follow.test.js @@ -0,0 +1,18 @@ +import { expect } from "chai"; +import FollowComponent from 'content/components/common/follow'; + +describe('FollowComponent', () => { + describe('#getTargetElements', () => { + beforeEach(() => { + document.body.innerHTML = __html__['test/content/components/common/follow.html']; + }); + + it('returns visible links', () => { + let targets = FollowComponent.getTargetElements(window); + expect(targets).to.have.lengthOf(3); + + let ids = Array.prototype.map.call(targets, (e) => e.id); + expect(ids).to.include.members(['visible_a', 'editable_div_1', 'editable_div_2']); + }); + }); +}); diff --git a/test/content/components/common/hint.html b/test/content/components/common/hint.html new file mode 100644 index 0000000..b50c5fe --- /dev/null +++ b/test/content/components/common/hint.html @@ -0,0 +1 @@ +link diff --git a/test/content/components/common/hint.test.js b/test/content/components/common/hint.test.js new file mode 100644 index 0000000..ced2fde --- /dev/null +++ b/test/content/components/common/hint.test.js @@ -0,0 +1,58 @@ +import { expect } from "chai"; +import Hint from 'content/components/common/hint'; + +describe('Hint class', () => { + beforeEach(() => { + document.body.innerHTML = __html__['test/content/components/common/hint.html']; + }); + + describe('#constructor', () => { + it('creates a hint element with tag name', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + expect(hint.element.textContent.trim()).to.be.equal('abc'); + }); + + it('throws an exception when non-element given', () => { + expect(() => new Hint(window, 'abc')).to.throw(TypeError); + }); + }); + + describe('#show', () => { + it('shows an element', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + hint.hide(); + hint.show(); + + expect(hint.element.style.display).to.not.equal('none'); + }); + }); + + describe('#hide', () => { + it('hides an element', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + hint.hide(); + + expect(hint.element.style.display).to.equal('none'); + }); + }); + + describe('#remove', () => { + it('removes an element', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + + expect(hint.element.parentElement).to.not.be.null; + hint.remove(); + expect(hint.element.parentElement).to.be.null; + }); + }); + + describe('#activate', () => { + // TODO test activations + }); +}); + + diff --git a/test/content/components/follow.html b/test/content/components/follow.html deleted file mode 100644 index eb0decd..0000000 --- a/test/content/components/follow.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - link - invisible 1 - invisible 2 - not link -
link
-
link
-
link
- - diff --git a/test/content/components/follow.test.js b/test/content/components/follow.test.js deleted file mode 100644 index c8ae58b..0000000 --- a/test/content/components/follow.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { expect } from "chai"; -import FollowComponent from 'content/components/follow'; - -describe('FollowComponent', () => { - describe('#getTargetElements', () => { - beforeEach(() => { - document.body.innerHTML = __html__['test/content/components/follow.html']; - }); - - it('returns visible links', () => { - let targets = FollowComponent.getTargetElements(window); - expect(targets).to.have.lengthOf(3); - - let ids = Array.prototype.map.call(targets, (e) => e.id); - expect(ids).to.include.members(['visible_a', 'editable_div_1', 'editable_div_2']); - }); - }); -}); diff --git a/test/content/components/hint.html b/test/content/components/hint.html deleted file mode 100644 index b50c5fe..0000000 --- a/test/content/components/hint.html +++ /dev/null @@ -1 +0,0 @@ -link diff --git a/test/content/components/hint.test.js b/test/content/components/hint.test.js deleted file mode 100644 index f98b79b..0000000 --- a/test/content/components/hint.test.js +++ /dev/null @@ -1,58 +0,0 @@ -import { expect } from "chai"; -import Hint from 'content/components/hint'; - -describe('Hint class', () => { - beforeEach(() => { - document.body.innerHTML = __html__['test/content/components/hint.html']; - }); - - describe('#constructor', () => { - it('creates a hint element with tag name', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - expect(hint.element.textContent.trim()).to.be.equal('abc'); - }); - - it('throws an exception when non-element given', () => { - expect(() => new Hint(window, 'abc')).to.throw(TypeError); - }); - }); - - describe('#show', () => { - it('shows an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - hint.hide(); - hint.show(); - - expect(hint.element.style.display).to.not.equal('none'); - }); - }); - - describe('#hide', () => { - it('hides an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - hint.hide(); - - expect(hint.element.style.display).to.equal('none'); - }); - }); - - describe('#remove', () => { - it('removes an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - - expect(hint.element.parentElement).to.not.be.null; - hint.remove(); - expect(hint.element.parentElement).to.be.null; - }); - }); - - describe('#activate', () => { - // TODO test activations - }); -}); - - -- cgit v1.2.3 From ac5354020e5efdf6e284d4b36696b9f94d46bef9 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 15 Oct 2017 12:34:26 +0900 Subject: support multi-frame following --- src/console/index.js | 4 +- src/content/actions/operation.js | 6 +- src/content/components/common/follow.js | 147 ++++++++++----------- src/content/components/common/index.js | 9 +- src/content/components/common/input.js | 3 + src/content/components/common/keymapper.js | 3 + src/content/components/frame-content.js | 4 +- src/content/components/top-content.js | 28 ---- .../components/top-content/follow-controller.js | 115 ++++++++++++++++ src/content/components/top-content/index.js | 32 +++++ src/content/index.js | 15 +++ src/shared/messages.js | 9 ++ 12 files changed, 257 insertions(+), 118 deletions(-) delete mode 100644 src/content/components/top-content.js create mode 100644 src/content/components/top-content/follow-controller.js create mode 100644 src/content/components/top-content/index.js (limited to 'src/content/components') diff --git a/src/console/index.js b/src/console/index.js index 895fcc2..2ae5779 100644 --- a/src/console/index.js +++ b/src/console/index.js @@ -36,6 +36,6 @@ store.subscribe(() => { }); browser.runtime.onMessage.addListener(onMessage); -window.addEventListener('message', (message) => { - onMessage(JSON.parse(message.data)); +window.addEventListener('message', (event) => { + onMessage(JSON.parse(event.data)); }, false); diff --git a/src/content/actions/operation.js b/src/content/actions/operation.js index 3aa9c1f..81bcc2f 100644 --- a/src/content/actions/operation.js +++ b/src/content/actions/operation.js @@ -3,7 +3,6 @@ import messages from 'shared/messages'; import * as scrolls from 'content/scrolls'; import * as navigates from 'content/navigates'; import * as urls from 'content/urls'; -import * as followActions from 'content/actions/follow'; import * as consoleFrames from 'content/console-frames'; const exec = (operation) => { @@ -23,7 +22,10 @@ const exec = (operation) => { case operations.SCROLL_END: return scrolls.scrollEnd(window); case operations.FOLLOW_START: - return followActions.enable(operation.newTab); + return window.top.postMessage(JSON.stringify({ + type: messages.FOLLOW_START, + newTab: operation.newTab + }), '*'); case operations.NAVIGATE_HISTORY_PREV: return navigates.historyPrev(window); case operations.NAVIGATE_HISTORY_NEXT: diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js index 3f28cc2..a5fbab4 100644 --- a/src/content/components/common/follow.js +++ b/src/content/components/common/follow.js @@ -1,9 +1,6 @@ -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=""]' @@ -21,77 +18,31 @@ const inWindow = (win, element) => { ); }; -export default class FollowComponent { +export default class Follow { constructor(win, store) { this.win = win; this.store = store; - this.hintElements = {}; - this.state = {}; + this.newTab = false; + this.hints = {}; + this.targets = []; } 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) { + if (Object.keys(this.hints).length === 0) { 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; - } + this.win.parent.postMessage(JSON.stringify({ + type: messages.FOLLOW_KEY_PRESS, + key, + }), '*'); 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) { + if (!this.newTab) { element.click(); return; } @@ -105,14 +56,56 @@ export default class FollowComponent { return browser.runtime.sendMessage({ type: messages.OPEN_URL, url: element.href, - newTab: this.state.newTab, + newTab: this.newTab, }); } - activate(element) { + countHints(sender) { + this.targets = Follow.getTargetElements(this.win); + sender.postMessage(JSON.stringify({ + type: messages.FOLLOW_RESPONSE_COUNT_TARGETS, + count: this.targets.length, + }), '*'); + } + + createHints(keysArray, newTab) { + if (keysArray.length !== this.targets.length) { + throw new Error('illegal hint count'); + } + + this.newTab = newTab; + this.hints = {}; + for (let i = 0; i < keysArray.length; ++i) { + let keys = keysArray[i]; + let hint = new Hint(this.targets[i], keys); + this.hints[keys] = hint; + } + } + + showHints(keys) { + Object.keys(this.hints).filter(key => key.startsWith(keys)) + .forEach(key => this.hints[key].show()); + Object.keys(this.hints).filter(key => !key.startsWith(keys)) + .forEach(key => this.hints[key].hide()); + } + + removeHints() { + Object.keys(this.hints).forEach((key) => { + this.hints[key].remove(); + }); + this.hints = {}; + this.targets = []; + } + + activateHints(keys) { + let hint = this.hints[keys]; + if (!hint) { + return; + } + let element = hint.target; switch (element.tagName.toLowerCase()) { case 'a': - return this.openLink(element, this.state.newTab); + return this.openLink(element, this.newTab); case 'input': switch (element.type) { case 'file': @@ -137,23 +130,19 @@ export default class FollowComponent { } } - 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(); - }); + onMessage(message, sender) { + switch (message.type) { + case messages.FOLLOW_REQUEST_COUNT_TARGETS: + return this.countHints(sender); + case messages.FOLLOW_CREATE_HINTS: + return this.createHints(message.keysArray, message.newTab); + case messages.FOLLOW_SHOW_HINTS: + return this.showHints(message.keys); + case messages.FOLLOW_ACTIVATE: + return this.activateHints(message.keys); + case messages.FOLLOW_REMOVE_HINTS: + return this.removeHints(message.keys); + } } static getTargetElements(win) { diff --git a/src/content/components/common/index.js b/src/content/components/common/index.js index 7673134..a05febd 100644 --- a/src/content/components/common/index.js +++ b/src/content/components/common/index.js @@ -10,10 +10,8 @@ export default class Common { 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); - }); + input.onKey((key, ctrl) => follow.key(key, ctrl)); + input.onKey((key, ctrl) => keymapper.key(key, ctrl)); this.store = store; this.children = [ @@ -29,11 +27,12 @@ export default class Common { this.children.forEach(c => c.update()); } - onMessage(message) { + onMessage(message, sender) { switch (message) { case messages.SETTINGS_CHANGED: this.reloadSettings(); } + this.children.forEach(c => c.onMessage(message, sender)); } reloadSettings() { diff --git a/src/content/components/common/input.js b/src/content/components/common/input.js index df09894..8a7f82a 100644 --- a/src/content/components/common/input.js +++ b/src/content/components/common/input.js @@ -69,4 +69,7 @@ export default class InputComponent { e.target.getAttribute('contenteditable').toLowerCase() === 'true' || e.target.getAttribute('contenteditable').toLowerCase() === ''); } + + onMessage() { + } } diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js index 655c3f2..2a57b28 100644 --- a/src/content/components/common/keymapper.js +++ b/src/content/components/common/keymapper.js @@ -28,4 +28,7 @@ export default class KeymapperComponent { this.store.dispatch(inputActions.clearKeys()); return true; } + + onMessage() { + } } diff --git a/src/content/components/frame-content.js b/src/content/components/frame-content.js index d2fb245..46786d2 100644 --- a/src/content/components/frame-content.js +++ b/src/content/components/frame-content.js @@ -10,7 +10,7 @@ export default class FrameContent { this.children.forEach(c => c.update()); } - onMessage(message) { - this.children.forEach(c => c.onMessage(message)); + onMessage(message, sender) { + this.children.forEach(c => c.onMessage(message, sender)); } } diff --git a/src/content/components/top-content.js b/src/content/components/top-content.js deleted file mode 100644 index 9b58947..0000000 --- a/src/content/components/top-content.js +++ /dev/null @@ -1,28 +0,0 @@ -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/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js new file mode 100644 index 0000000..0474690 --- /dev/null +++ b/src/content/components/top-content/follow-controller.js @@ -0,0 +1,115 @@ +import * as followActions from 'content/actions/follow'; +import messages from 'shared/messages'; +import HintKeyProducer from 'content/hint-key-producer'; + +const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz'; + +const broadcastMessage = (win, message) => { + let json = JSON.stringify(message); + let frames = [window.self].concat(Array.from(window.frames)); + frames.forEach(frame => frame.postMessage(json, '*')); +}; + +export default class FollowController { + constructor(win, store) { + this.win = win; + this.store = store; + this.state = {}; + this.keys = []; + this.producer = null; + } + + onMessage(message, sender) { + switch (message.type) { + case messages.FOLLOW_START: + return this.store.dispatch(followActions.enable(message.newTab)); + case messages.FOLLOW_RESPONSE_COUNT_TARGETS: + return this.create(message.count, sender); + case messages.FOLLOW_KEY_PRESS: + return this.keyPress(message.key); + } + } + + update() { + let prevState = this.state; + this.state = this.store.getState().follow; + + if (!prevState.enabled && this.state.enabled) { + this.count(); + } else if (prevState.enabled && !this.state.enabled) { + this.remove(); + } else if (prevState.keys !== this.state.keys) { + this.updateHints(); + } + } + + updateHints() { + let shown = this.keys.filter(key => key.startsWith(this.state.keys)); + if (shown.length === 1) { + this.activate(); + this.store.dispatch(followActions.disable()); + } + + broadcastMessage(this.win, { + type: messages.FOLLOW_SHOW_HINTS, + keys: this.state.keys, + }); + } + + activate() { + broadcastMessage(this.win, { + type: messages.FOLLOW_ACTIVATE, + keys: this.state.keys, + }); + } + + keyPress(key) { + switch (key) { + case 'Enter': + this.activate(); + this.store.dispatch(followActions.disable()); + break; + case 'Escape': + this.store.dispatch(followActions.disable()); + break; + 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; + } + + count() { + this.producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); + broadcastMessage(this.win, { + type: messages.FOLLOW_REQUEST_COUNT_TARGETS, + }); + } + + create(count, sender) { + let produced = []; + for (let i = 0; i < count; ++i) { + produced.push(this.producer.produce()); + } + this.keys = this.keys.concat(produced); + + sender.postMessage(JSON.stringify({ + type: messages.FOLLOW_CREATE_HINTS, + keysArray: produced, + newTab: this.state.newTab, + }), '*'); + } + + remove() { + this.keys = []; + broadcastMessage(this.win, { + type: messages.FOLLOW_REMOVE_HINTS, + }); + } +} diff --git a/src/content/components/top-content/index.js b/src/content/components/top-content/index.js new file mode 100644 index 0000000..a2179da --- /dev/null +++ b/src/content/components/top-content/index.js @@ -0,0 +1,32 @@ +import CommonComponent from '../common'; +import FollowController from './follow-controller'; +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), + new FollowController(win, store), + ]; + + // TODO make component + consoleFrames.initialize(window.document); + } + + update() { + this.children.forEach(c => c.update()); + } + + onMessage(message, sender) { + 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, sender)); + } +} diff --git a/src/content/index.js b/src/content/index.js index 589eb98..e01172d 100644 --- a/src/content/index.js +++ b/src/content/index.js @@ -10,5 +10,20 @@ let rootComponent = window.self === window.top ? new TopContentComponent(window, store) : new FrameContentComponent(window, store); +store.subscribe(() => { + rootComponent.update(); +}); + browser.runtime.onMessage.addListener(msg => rootComponent.onMessage(msg)); rootComponent.update(); + +window.addEventListener('message', (event) => { + let message = null; + try { + message = JSON.parse(event.data); + } catch (e) { + // ignore unexpected message + return; + } + rootComponent.onMessage(message, event.source); +}); diff --git a/src/shared/messages.js b/src/shared/messages.js index 2467a67..581e167 100644 --- a/src/shared/messages.js +++ b/src/shared/messages.js @@ -11,6 +11,15 @@ export default { CONSOLE_SHOW_INFO: 'console.show.info', CONSOLE_HIDE_COMMAND: 'console.hide.command', + FOLLOW_START: 'follow.start', + FOLLOW_REQUEST_COUNT_TARGETS: 'follow.request.count.targets', + FOLLOW_RESPONSE_COUNT_TARGETS: 'follow.response.count.targets', + FOLLOW_CREATE_HINTS: 'follow.create.hints', + FOLLOW_SHOW_HINTS: 'follow.update.hints', + FOLLOW_REMOVE_HINTS: 'follow.remove.hints', + FOLLOW_ACTIVATE: 'follow.activate', + FOLLOW_KEY_PRESS: 'follow.key.press', + OPEN_URL: 'open.url', SETTINGS_RELOAD: 'settings.reload', -- cgit v1.2.3 From 45b3b0510fd0babe392ee46319fa345433af7736 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 15 Oct 2017 21:59:37 +0900 Subject: follow links for multi-frames in viewport --- .eslintrc | 1 + src/content/components/common/follow.js | 31 ++++++++++++++-------- .../components/top-content/follow-controller.js | 18 ++++++++++++- test/content/components/common/follow.test.js | 5 +++- 4 files changed, 42 insertions(+), 13 deletions(-) (limited to 'src/content/components') diff --git a/.eslintrc b/.eslintrc index d244c8f..5636171 100644 --- a/.eslintrc +++ b/.eslintrc @@ -29,6 +29,7 @@ "id-length": "off", "indent": ["error", 2], "jsx-quotes": ["error", "prefer-single"], + "max-params": ["error", 5], "max-statements": ["error", 15], "multiline-ternary": "off", "newline-after-var": "off", diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js index a5fbab4..92d8822 100644 --- a/src/content/components/common/follow.js +++ b/src/content/components/common/follow.js @@ -6,16 +6,25 @@ const TARGET_SELECTOR = [ '[contenteditable=true]', '[contenteditable=""]' ].join(','); -const inWindow = (win, element) => { +const inViewport = (win, element, viewSize, framePosition) => { 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) - ); + let frameWidth = win.innerWidth || doc.documentElement.clientWidth; + let frameHeight = win.innerHeight || doc.documentElement.clientHeight; + + if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) { + // out of frame + return false; + } + if (right + framePosition.x < 0 || bottom + framePosition.y < 0 || + left + framePosition.x > viewSize.width || + top + framePosition.y > viewSize.height) { + // out of viewport + return false; + } + return true; }; export default class Follow { @@ -60,8 +69,8 @@ export default class Follow { }); } - countHints(sender) { - this.targets = Follow.getTargetElements(this.win); + countHints(sender, viewSize, framePosition) { + this.targets = Follow.getTargetElements(this.win, viewSize, framePosition); sender.postMessage(JSON.stringify({ type: messages.FOLLOW_RESPONSE_COUNT_TARGETS, count: this.targets.length, @@ -133,7 +142,7 @@ export default class Follow { onMessage(message, sender) { switch (message.type) { case messages.FOLLOW_REQUEST_COUNT_TARGETS: - return this.countHints(sender); + return this.countHints(sender, message.viewSize, message.framePosition); case messages.FOLLOW_CREATE_HINTS: return this.createHints(message.keysArray, message.newTab); case messages.FOLLOW_SHOW_HINTS: @@ -145,7 +154,7 @@ export default class Follow { } } - static getTargetElements(win) { + static getTargetElements(win, viewSize, framePosition) { let all = win.document.querySelectorAll(TARGET_SELECTOR); let filtered = Array.prototype.filter.call(all, (element) => { let style = win.getComputedStyle(element); @@ -153,7 +162,7 @@ export default class Follow { style.visibility !== 'hidden' && element.type !== 'hidden' && element.offsetHeight > 0 && - inWindow(win, element); + inViewport(win, element, viewSize, framePosition); }); return filtered; } diff --git a/src/content/components/top-content/follow-controller.js b/src/content/components/top-content/follow-controller.js index 0474690..29f40b3 100644 --- a/src/content/components/top-content/follow-controller.js +++ b/src/content/components/top-content/follow-controller.js @@ -87,8 +87,24 @@ export default class FollowController { count() { this.producer = new HintKeyProducer(DEFAULT_HINT_CHARSET); - broadcastMessage(this.win, { + let doc = this.win.document; + let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; + let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; + let frameElements = this.win.document.querySelectorAll('frame,iframe'); + + this.win.postMessage(JSON.stringify({ type: messages.FOLLOW_REQUEST_COUNT_TARGETS, + viewSize: { width: viewWidth, height: viewHeight }, + framePosition: { x: 0, y: 0 }, + }), '*'); + frameElements.forEach((element) => { + let { left: frameX, top: frameY } = element.getBoundingClientRect(); + let message = JSON.stringify({ + type: messages.FOLLOW_REQUEST_COUNT_TARGETS, + viewSize: { width: viewWidth, height: viewHeight }, + framePosition: { x: frameX, y: frameY }, + }); + element.contentWindow.postMessage(message, '*'); }); } diff --git a/test/content/components/common/follow.test.js b/test/content/components/common/follow.test.js index 97bd1d2..1fc935e 100644 --- a/test/content/components/common/follow.test.js +++ b/test/content/components/common/follow.test.js @@ -8,7 +8,10 @@ describe('FollowComponent', () => { }); it('returns visible links', () => { - let targets = FollowComponent.getTargetElements(window); + let targets = FollowComponent.getTargetElements( + window, + { width: window.innerWidth, height: window.innerHeight }, + { x: 0, y: 0 }); expect(targets).to.have.lengthOf(3); let ids = Array.prototype.map.call(targets, (e) => e.id); -- cgit v1.2.3