aboutsummaryrefslogtreecommitdiff
path: root/src/content
diff options
context:
space:
mode:
Diffstat (limited to 'src/content')
-rw-r--r--src/content/actions/operation.js6
-rw-r--r--src/content/components/common/follow.js169
-rw-r--r--src/content/components/common/hint.css (renamed from src/content/hint.css)0
-rw-r--r--src/content/components/common/hint.js (renamed from src/content/hint.js)0
-rw-r--r--src/content/components/common/index.js45
-rw-r--r--src/content/components/common/input.js (renamed from src/content/components/content-input.js)5
-rw-r--r--src/content/components/common/keymapper.js (renamed from src/content/components/keymapper.js)3
-rw-r--r--src/content/components/follow.js175
-rw-r--r--src/content/components/frame-content.js16
-rw-r--r--src/content/components/top-content/follow-controller.js131
-rw-r--r--src/content/components/top-content/index.js32
-rw-r--r--src/content/index.js61
12 files changed, 422 insertions, 221 deletions
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
new file mode 100644
index 0000000..92d8822
--- /dev/null
+++ b/src/content/components/common/follow.js
@@ -0,0 +1,169 @@
+import messages from 'shared/messages';
+import Hint from './hint';
+
+const TARGET_SELECTOR = [
+ 'a', 'button', 'input', 'textarea',
+ '[contenteditable=true]', '[contenteditable=""]'
+].join(',');
+
+const inViewport = (win, element, viewSize, framePosition) => {
+ let {
+ top, left, bottom, right
+ } = element.getBoundingClientRect();
+ let doc = win.doc;
+ 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 {
+ constructor(win, store) {
+ this.win = win;
+ this.store = store;
+ this.newTab = false;
+ this.hints = {};
+ this.targets = [];
+ }
+
+ update() {
+ }
+
+ key(key) {
+ if (Object.keys(this.hints).length === 0) {
+ return false;
+ }
+ this.win.parent.postMessage(JSON.stringify({
+ type: messages.FOLLOW_KEY_PRESS,
+ key,
+ }), '*');
+ return true;
+ }
+
+ openLink(element) {
+ if (!this.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.newTab,
+ });
+ }
+
+ 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,
+ }), '*');
+ }
+
+ 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.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();
+ }
+ }
+
+ onMessage(message, sender) {
+ switch (message.type) {
+ case messages.FOLLOW_REQUEST_COUNT_TARGETS:
+ 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:
+ 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, viewSize, framePosition) {
+ 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 &&
+ inViewport(win, element, viewSize, framePosition);
+ });
+ return filtered;
+ }
+}
diff --git a/src/content/hint.css b/src/content/components/common/hint.css
index 119dd21..119dd21 100644
--- a/src/content/hint.css
+++ b/src/content/components/common/hint.css
diff --git a/src/content/hint.js b/src/content/components/common/hint.js
index cc46fd6..cc46fd6 100644
--- a/src/content/hint.js
+++ b/src/content/components/common/hint.js
diff --git a/src/content/components/common/index.js b/src/content/components/common/index.js
new file mode 100644
index 0000000..a05febd
--- /dev/null
+++ b/src/content/components/common/index.js
@@ -0,0 +1,45 @@
+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));
+ input.onKey((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, sender) {
+ switch (message) {
+ case messages.SETTINGS_CHANGED:
+ this.reloadSettings();
+ }
+ this.children.forEach(c => c.onMessage(message, sender));
+ }
+
+ reloadSettings() {
+ browser.runtime.sendMessage({
+ type: messages.SETTINGS_QUERY,
+ }).then((settings) => {
+ this.store.dispatch(inputActions.setKeymaps(settings.keymaps));
+ });
+ }
+}
diff --git a/src/content/components/content-input.js b/src/content/components/common/input.js
index 3e70bbb..8a7f82a 100644
--- a/src/content/components/content-input.js
+++ b/src/content/components/common/input.js
@@ -1,4 +1,4 @@
-export default class ContentInputComponent {
+export default class InputComponent {
constructor(target) {
this.pressed = {};
this.onKeyListeners = [];
@@ -69,4 +69,7 @@ export default class ContentInputComponent {
e.target.getAttribute('contenteditable').toLowerCase() === 'true' ||
e.target.getAttribute('contenteditable').toLowerCase() === '');
}
+
+ onMessage() {
+ }
}
diff --git a/src/content/components/keymapper.js b/src/content/components/common/keymapper.js
index 655c3f2..2a57b28 100644
--- a/src/content/components/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/follow.js b/src/content/components/follow.js
deleted file mode 100644
index eb453a5..0000000
--- a/src/content/components/follow.js
+++ /dev/null
@@ -1,175 +0,0 @@
-import * as followActions from 'content/actions/follow';
-import messages from 'shared/messages';
-import Hint from 'content/hint';
-import HintKeyProducer from 'content/hint-key-producer';
-
-const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
-const TARGET_SELECTOR = [
- 'a', 'button', 'input', 'textarea',
- '[contenteditable=true]', '[contenteditable=""]'
-].join(',');
-
-const inWindow = (window, element) => {
- let {
- top, left, bottom, right
- } = element.getBoundingClientRect();
- return (
- top >= 0 && left >= 0 &&
- bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
- right <= (window.innerWidth || document.documentElement.clientWidth)
- );
-};
-
-export default class FollowComponent {
- constructor(wrapper, store) {
- this.wrapper = wrapper;
- this.store = store;
- this.hintElements = {};
- this.state = {};
- }
-
- update() {
- let prevState = this.state;
- this.state = this.store.getState().follow;
- if (!prevState.enabled && this.state.enabled) {
- this.create();
- } else if (prevState.enabled && !this.state.enabled) {
- this.remove();
- } else if (prevState.keys !== this.state.keys) {
- this.updateHints();
- }
- }
-
- key(key) {
- if (!this.state.enabled) {
- return false;
- }
-
- switch (key) {
- case 'Enter':
- this.activate(this.hintElements[this.state.keys].target);
- return;
- case 'Escape':
- this.store.dispatch(followActions.disable());
- return;
- case 'Backspace':
- case 'Delete':
- this.store.dispatch(followActions.backspace());
- break;
- default:
- if (DEFAULT_HINT_CHARSET.includes(key)) {
- this.store.dispatch(followActions.keyPress(key));
- }
- break;
- }
- return true;
- }
-
- updateHints() {
- let keys = this.state.keys;
- let shown = Object.keys(this.hintElements).filter((key) => {
- return key.startsWith(keys);
- });
- let hidden = Object.keys(this.hintElements).filter((key) => {
- return !key.startsWith(keys);
- });
- if (shown.length === 0) {
- this.remove();
- return;
- } else if (shown.length === 1) {
- this.activate(this.hintElements[keys].target);
- this.store.dispatch(followActions.disable());
- }
-
- shown.forEach((key) => {
- this.hintElements[key].show();
- });
- hidden.forEach((key) => {
- this.hintElements[key].hide();
- });
- }
-
- activate(element) {
- switch (element.tagName.toLowerCase()) {
- case 'a':
- if (this.state.newTab) {
- // getAttribute() to avoid to resolve absolute path
- let href = element.getAttribute('href');
-
- // eslint-disable-next-line no-script-url
- if (!href || href === '#' || href.startsWith('javascript:')) {
- return;
- }
- return browser.runtime.sendMessage({
- type: messages.OPEN_URL,
- url: element.href,
- newTab: this.state.newTab,
- });
- }
- if (element.href.startsWith('http://') ||
- element.href.startsWith('https://') ||
- element.href.startsWith('ftp://')) {
- return browser.runtime.sendMessage({
- type: messages.OPEN_URL,
- url: element.href,
- newTab: this.state.newTab,
- });
- }
- return element.click();
- case 'input':
- switch (element.type) {
- case 'file':
- case 'checkbox':
- case 'radio':
- case 'submit':
- case 'reset':
- case 'button':
- case 'image':
- case 'color':
- return element.click();
- default:
- return element.focus();
- }
- case 'textarea':
- return element.focus();
- case 'button':
- return element.click();
- default:
- // it may contenteditable
- return element.focus();
- }
- }
-
- create() {
- let doc = this.wrapper.ownerDocument;
- let elements = FollowComponent.getTargetElements(doc);
- let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET);
- let hintElements = {};
- Array.prototype.forEach.call(elements, (ele) => {
- let keys = producer.produce();
- let hint = new Hint(ele, keys);
- hintElements[keys] = hint;
- });
- this.hintElements = hintElements;
- }
-
- remove() {
- let hintElements = this.hintElements;
- Object.keys(this.hintElements).forEach((key) => {
- hintElements[key].remove();
- });
- }
-
- static getTargetElements(doc) {
- let all = doc.querySelectorAll(TARGET_SELECTOR);
- let filtered = Array.prototype.filter.call(all, (element) => {
- let style = window.getComputedStyle(element);
- return style.display !== 'none' &&
- style.visibility !== 'hidden' &&
- element.type !== 'hidden' &&
- element.offsetHeight > 0 &&
- inWindow(window, element);
- });
- return filtered;
- }
-}
diff --git a/src/content/components/frame-content.js b/src/content/components/frame-content.js
new file mode 100644
index 0000000..46786d2
--- /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, sender) {
+ this.children.forEach(c => c.onMessage(message, sender));
+ }
+}
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..29f40b3
--- /dev/null
+++ b/src/content/components/top-content/follow-controller.js
@@ -0,0 +1,131 @@
+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);
+ 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, '*');
+ });
+ }
+
+ 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 64d86bb..e01172d 100644
--- a/src/content/index.js
+++ b/src/content/index.js
@@ -1,54 +1,29 @@
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.document.body, 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);
-});
+
+let rootComponent = window.self === window.top
+ ? new TopContentComponent(window, store)
+ : new FrameContentComponent(window, store);
+
store.subscribe(() => {
- try {
- followComponent.update();
- contentInputComponent.update();
- } catch (e) {
- console.error(e);
- }
+ rootComponent.update();
});
-consoleFrames.initialize(window.document);
-
-const reloadSettings = () => {
- return browser.runtime.sendMessage({
- type: messages.SETTINGS_QUERY,
- }).then((settings) => {
- store.dispatch(inputActions.setKeymaps(settings.keymaps));
- });
-};
+browser.runtime.onMessage.addListener(msg => rootComponent.onMessage(msg));
+rootComponent.update();
-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();
+window.addEventListener('message', (event) => {
+ let message = null;
+ try {
+ message = JSON.parse(event.data);
+ } catch (e) {
+ // ignore unexpected message
+ return;
}
+ rootComponent.onMessage(message, event.source);
});
-
-reloadSettings();