aboutsummaryrefslogtreecommitdiff
path: root/src/content/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/content/components')
-rw-r--r--src/content/components/common/follow.js169
-rw-r--r--src/content/components/common/hint.css10
-rw-r--r--src/content/components/common/hint.js36
-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
10 files changed, 446 insertions, 176 deletions
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/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..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));
+ }
+}