aboutsummaryrefslogtreecommitdiff
path: root/src/content/components/common
diff options
context:
space:
mode:
authorShin'ya Ueoka <ueokande@i-beam.org>2017-10-15 09:02:09 +0900
committerShin'ya Ueoka <ueokande@i-beam.org>2017-10-15 09:04:00 +0900
commit4c9d0433a6ac851e72d50d6fb0451baa9d35fd35 (patch)
tree5b831137fe327e94fd73339e2b395bfe3e98961c /src/content/components/common
parent042aa94936c9114f0a0fd05fb0a91df8f5565ecd (diff)
make top-content component and frame-content component
Diffstat (limited to 'src/content/components/common')
-rw-r--r--src/content/components/common/follow.js171
-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.js46
-rw-r--r--src/content/components/common/input.js72
-rw-r--r--src/content/components/common/keymapper.js31
6 files changed, 366 insertions, 0 deletions
diff --git a/src/content/components/common/follow.js b/src/content/components/common/follow.js
new file mode 100644
index 0000000..3f28cc2
--- /dev/null
+++ b/src/content/components/common/follow.js
@@ -0,0 +1,171 @@
+import * as followActions from 'content/actions/follow';
+import messages from 'shared/messages';
+import Hint from './hint';
+import HintKeyProducer from 'content/hint-key-producer';
+
+const DEFAULT_HINT_CHARSET = 'abcdefghijklmnopqrstuvwxyz';
+const TARGET_SELECTOR = [
+ 'a', 'button', 'input', 'textarea',
+ '[contenteditable=true]', '[contenteditable=""]'
+].join(',');
+
+const inWindow = (win, element) => {
+ let {
+ top, left, bottom, right
+ } = element.getBoundingClientRect();
+ let doc = win.doc;
+ return (
+ top >= 0 && left >= 0 &&
+ bottom <= (win.innerHeight || doc.documentElement.clientHeight) &&
+ right <= (win.innerWidth || doc.documentElement.clientWidth)
+ );
+};
+
+export default class FollowComponent {
+ constructor(win, store) {
+ this.win = win;
+ this.store = store;
+ this.hintElements = {};
+ this.state = {};
+ }
+
+ update() {
+ let prevState = this.state;
+ this.state = this.store.getState().follow;
+ if (!prevState.enabled && this.state.enabled) {
+ this.create();
+ } else if (prevState.enabled && !this.state.enabled) {
+ this.remove();
+ } else if (prevState.keys !== this.state.keys) {
+ this.updateHints();
+ }
+ }
+
+ key(key) {
+ if (!this.state.enabled) {
+ return false;
+ }
+
+ switch (key) {
+ case 'Enter':
+ this.activate(this.hintElements[this.state.keys].target);
+ return;
+ case 'Escape':
+ this.store.dispatch(followActions.disable());
+ return;
+ case 'Backspace':
+ case 'Delete':
+ this.store.dispatch(followActions.backspace());
+ break;
+ default:
+ if (DEFAULT_HINT_CHARSET.includes(key)) {
+ this.store.dispatch(followActions.keyPress(key));
+ }
+ break;
+ }
+ return true;
+ }
+
+ updateHints() {
+ let keys = this.state.keys;
+ let shown = Object.keys(this.hintElements).filter((key) => {
+ return key.startsWith(keys);
+ });
+ let hidden = Object.keys(this.hintElements).filter((key) => {
+ return !key.startsWith(keys);
+ });
+ if (shown.length === 0) {
+ this.remove();
+ return;
+ } else if (shown.length === 1) {
+ this.activate(this.hintElements[keys].target);
+ this.store.dispatch(followActions.disable());
+ }
+
+ shown.forEach((key) => {
+ this.hintElements[key].show();
+ });
+ hidden.forEach((key) => {
+ this.hintElements[key].hide();
+ });
+ }
+
+ openLink(element) {
+ if (!this.state.newTab) {
+ element.click();
+ return;
+ }
+
+ let href = element.getAttribute('href');
+
+ // eslint-disable-next-line no-script-url
+ if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) {
+ return;
+ }
+ return browser.runtime.sendMessage({
+ type: messages.OPEN_URL,
+ url: element.href,
+ newTab: this.state.newTab,
+ });
+ }
+
+ activate(element) {
+ switch (element.tagName.toLowerCase()) {
+ case 'a':
+ return this.openLink(element, this.state.newTab);
+ case 'input':
+ switch (element.type) {
+ case 'file':
+ case 'checkbox':
+ case 'radio':
+ case 'submit':
+ case 'reset':
+ case 'button':
+ case 'image':
+ case 'color':
+ return element.click();
+ default:
+ return element.focus();
+ }
+ case 'textarea':
+ return element.focus();
+ case 'button':
+ return element.click();
+ default:
+ // it may contenteditable
+ return element.focus();
+ }
+ }
+
+ create() {
+ let elements = FollowComponent.getTargetElements(this.win);
+ let producer = new HintKeyProducer(DEFAULT_HINT_CHARSET);
+ let hintElements = {};
+ Array.prototype.forEach.call(elements, (ele) => {
+ let keys = producer.produce();
+ let hint = new Hint(ele, keys);
+ hintElements[keys] = hint;
+ });
+ this.hintElements = hintElements;
+ }
+
+ remove() {
+ let hintElements = this.hintElements;
+ Object.keys(this.hintElements).forEach((key) => {
+ hintElements[key].remove();
+ });
+ }
+
+ static getTargetElements(win) {
+ let all = win.document.querySelectorAll(TARGET_SELECTOR);
+ let filtered = Array.prototype.filter.call(all, (element) => {
+ let style = win.getComputedStyle(element);
+ return style.display !== 'none' &&
+ style.visibility !== 'hidden' &&
+ element.type !== 'hidden' &&
+ element.offsetHeight > 0 &&
+ inWindow(win, element);
+ });
+ return filtered;
+ }
+}
diff --git a/src/content/components/common/hint.css b/src/content/components/common/hint.css
new file mode 100644
index 0000000..119dd21
--- /dev/null
+++ b/src/content/components/common/hint.css
@@ -0,0 +1,10 @@
+.vimvixen-hint {
+ background-color: yellow;
+ border: 1px solid gold;
+ font-weight: bold;
+ position: absolute;
+ text-transform: uppercase;
+ z-index: 100000;
+ font-size: 12px;
+ color: black;
+}
diff --git a/src/content/components/common/hint.js b/src/content/components/common/hint.js
new file mode 100644
index 0000000..cc46fd6
--- /dev/null
+++ b/src/content/components/common/hint.js
@@ -0,0 +1,36 @@
+import './hint.css';
+
+export default class Hint {
+ constructor(target, tag) {
+ if (!(document.body instanceof HTMLElement)) {
+ throw new TypeError('target is not an HTMLElement');
+ }
+
+ this.target = target;
+
+ let doc = target.ownerDocument;
+ let { top, left } = target.getBoundingClientRect();
+ let { scrollX, scrollY } = window;
+
+ this.element = doc.createElement('span');
+ this.element.className = 'vimvixen-hint';
+ this.element.textContent = tag;
+ this.element.style.left = left + scrollX + 'px';
+ this.element.style.top = top + scrollY + 'px';
+
+ this.show();
+ doc.body.append(this.element);
+ }
+
+ show() {
+ this.element.style.display = 'inline';
+ }
+
+ hide() {
+ this.element.style.display = 'none';
+ }
+
+ remove() {
+ this.element.remove();
+ }
+}
diff --git a/src/content/components/common/index.js b/src/content/components/common/index.js
new file mode 100644
index 0000000..7673134
--- /dev/null
+++ b/src/content/components/common/index.js
@@ -0,0 +1,46 @@
+import InputComponent from './input';
+import KeymapperComponent from './keymapper';
+import FollowComponent from './follow';
+import * as inputActions from 'content/actions/input';
+import messages from 'shared/messages';
+
+export default class Common {
+ constructor(win, store) {
+ const follow = new FollowComponent(win, store);
+ const input = new InputComponent(win.document.body, store);
+ const keymapper = new KeymapperComponent(store);
+
+ input.onKey((key, ctrl) => {
+ follow.key(key, ctrl);
+ keymapper.key(key, ctrl);
+ });
+
+ this.store = store;
+ this.children = [
+ follow,
+ input,
+ keymapper,
+ ];
+
+ this.reloadSettings();
+ }
+
+ update() {
+ this.children.forEach(c => c.update());
+ }
+
+ onMessage(message) {
+ switch (message) {
+ case messages.SETTINGS_CHANGED:
+ this.reloadSettings();
+ }
+ }
+
+ reloadSettings() {
+ browser.runtime.sendMessage({
+ type: messages.SETTINGS_QUERY,
+ }).then((settings) => {
+ this.store.dispatch(inputActions.setKeymaps(settings.keymaps));
+ });
+ }
+}
diff --git a/src/content/components/common/input.js b/src/content/components/common/input.js
new file mode 100644
index 0000000..df09894
--- /dev/null
+++ b/src/content/components/common/input.js
@@ -0,0 +1,72 @@
+export default class InputComponent {
+ constructor(target) {
+ this.pressed = {};
+ this.onKeyListeners = [];
+
+ target.addEventListener('keypress', this.onKeyPress.bind(this));
+ target.addEventListener('keydown', this.onKeyDown.bind(this));
+ target.addEventListener('keyup', this.onKeyUp.bind(this));
+ }
+
+ update() {
+ }
+
+ onKey(cb) {
+ this.onKeyListeners.push(cb);
+ }
+
+ onKeyPress(e) {
+ if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') {
+ return;
+ }
+ this.pressed[e.key] = 'keypress';
+ this.capture(e);
+ }
+
+ onKeyDown(e) {
+ if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') {
+ return;
+ }
+ this.pressed[e.key] = 'keydown';
+ this.capture(e);
+ }
+
+ onKeyUp(e) {
+ delete this.pressed[e.key];
+ }
+
+ capture(e) {
+ if (this.fromInput(e)) {
+ if (e.key === 'Escape' && e.target.blur) {
+ e.target.blur();
+ }
+ return;
+ }
+ if (['Shift', 'Control', 'Alt', 'OS'].includes(e.key)) {
+ // pressing only meta key is ignored
+ return;
+ }
+
+ let stop = false;
+ for (let listener of this.onKeyListeners) {
+ stop = stop || listener(e.key, e.ctrlKey);
+ if (stop) {
+ break;
+ }
+ }
+ if (stop) {
+ e.preventDefault();
+ e.stopPropagation();
+ }
+ }
+
+ fromInput(e) {
+ return e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLSelectElement ||
+ e.target instanceof HTMLElement &&
+ e.target.hasAttribute('contenteditable') && (
+ e.target.getAttribute('contenteditable').toLowerCase() === 'true' ||
+ e.target.getAttribute('contenteditable').toLowerCase() === '');
+ }
+}
diff --git a/src/content/components/common/keymapper.js b/src/content/components/common/keymapper.js
new file mode 100644
index 0000000..655c3f2
--- /dev/null
+++ b/src/content/components/common/keymapper.js
@@ -0,0 +1,31 @@
+import * as inputActions from 'content/actions/input';
+import * as operationActions from 'content/actions/operation';
+
+export default class KeymapperComponent {
+ constructor(store) {
+ this.store = store;
+ }
+
+ update() {
+ }
+
+ key(key, ctrl) {
+ this.store.dispatch(inputActions.keyPress(key, ctrl));
+
+ let input = this.store.getState().input;
+ let matched = Object.keys(input.keymaps).filter((keyStr) => {
+ return keyStr.startsWith(input.keys);
+ });
+ if (matched.length === 0) {
+ this.store.dispatch(inputActions.clearKeys());
+ return false;
+ } else if (matched.length > 1 ||
+ matched.length === 1 && input.keys !== matched[0]) {
+ return true;
+ }
+ let operation = input.keymaps[matched];
+ this.store.dispatch(operationActions.exec(operation));
+ this.store.dispatch(inputActions.clearKeys());
+ return true;
+ }
+}