Merge pull request #61 from ueokande/multi-frame-following

Multi frame following
jh-changes
Shin'ya Ueoka 7 years ago committed by GitHub
commit 33a97a0e8c
  1. 2
      .eslintrc
  2. 1
      manifest.json
  3. 12
      src/background/components/background.js
  4. 6
      src/background/index.js
  5. 12
      src/background/reducers/index.js
  6. 4
      src/console/index.js
  7. 6
      src/content/actions/operation.js
  8. 169
      src/content/components/common/follow.js
  9. 0
      src/content/components/common/hint.css
  10. 0
      src/content/components/common/hint.js
  11. 45
      src/content/components/common/index.js
  12. 5
      src/content/components/common/input.js
  13. 3
      src/content/components/common/keymapper.js
  14. 175
      src/content/components/follow.js
  15. 16
      src/content/components/frame-content.js
  16. 131
      src/content/components/top-content/follow-controller.js
  17. 32
      src/content/components/top-content/index.js
  18. 61
      src/content/index.js
  19. 9
      src/shared/messages.js
  20. 0
      test/content/components/common/follow.html
  21. 9
      test/content/components/common/follow.test.js
  22. 0
      test/content/components/common/hint.html
  23. 4
      test/content/components/common/hint.test.js

@ -29,7 +29,9 @@
"id-length": "off",
"indent": ["error", 2],
"jsx-quotes": ["error", "prefer-single"],
"max-params": ["error", 5],
"max-statements": ["error", 15],
"multiline-comment-style": "off",
"multiline-ternary": "off",
"newline-after-var": "off",
"newline-before-return": "off",

@ -14,6 +14,7 @@
},
"content_scripts": [
{
"all_frames": true,
"matches": [ "http://*/*", "https://*/*" ],
"js": [ "build/content.js" ]
}

@ -7,7 +7,6 @@ import * as commands from 'shared/commands';
export default class BackgroundComponent {
constructor(store) {
this.store = store;
this.setting = {};
browser.runtime.onMessage.addListener((message, sender) => {
try {
@ -21,11 +20,8 @@ export default class BackgroundComponent {
});
}
update() {
this.settings = this.store.getState();
}
onMessage(message, sender) {
let settings = this.store.getState().setting;
switch (message.type) {
case messages.BACKGROUND_OPERATION:
return this.store.dispatch(
@ -43,16 +39,16 @@ export default class BackgroundComponent {
type: messages.CONSOLE_HIDE_COMMAND,
});
case messages.CONSOLE_ENTERED:
return commands.exec(message.text, this.settings.value).catch((e) => {
return commands.exec(message.text, settings.value).catch((e) => {
return browser.tabs.sendMessage(sender.tab.id, {
type: messages.CONSOLE_SHOW_ERROR,
text: e.message,
});
});
case messages.SETTINGS_QUERY:
return Promise.resolve(this.store.getState().value);
return Promise.resolve(this.store.getState().setting.value);
case messages.CONSOLE_QUERY_COMPLETIONS:
return commands.complete(message.text, this.settings.value);
return commands.complete(message.text, settings.value);
case messages.SETTINGS_RELOAD:
this.store.dispatch(settingsActions.load());
return this.broadcastSettingsChanged();

@ -1,7 +1,7 @@
import * as settingsActions from 'settings/actions/setting';
import messages from 'shared/messages';
import BackgroundComponent from 'background/components/background';
import reducers from 'settings/reducers/setting';
import reducers from 'background/reducers';
import { createStore } from 'shared/store';
const store = createStore(reducers, (e, sender) => {
@ -13,9 +13,7 @@ const store = createStore(reducers, (e, sender) => {
});
}
});
// eslint-disable-next-line no-unused-vars
const backgroundComponent = new BackgroundComponent(store);
store.subscribe((sender) => {
backgroundComponent.update(sender);
});
store.dispatch(settingsActions.load());

@ -0,0 +1,12 @@
import settingReducer from 'settings/reducers/setting';
// Make setting reducer instead of re-use
const defaultState = {
setting: settingReducer(undefined, {}),
};
export default function reducer(state = defaultState, action = {}) {
return Object.assign({}, state, {
setting: settingReducer(state.setting, action),
});
}

@ -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);

@ -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:

@ -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;
}
}

@ -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));
});
}
}

@ -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() {
}
}

@ -28,4 +28,7 @@ export default class KeymapperComponent {
this.store.dispatch(inputActions.clearKeys());
return true;
}
onMessage() {
}
}

@ -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;
}
}

@ -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));
}
}

@ -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,
});
}
}

@ -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));
}
}

@ -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();

@ -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',

@ -1,14 +1,17 @@
import { expect } from "chai";
import FollowComponent from 'content/components/follow';
import FollowComponent from 'content/components/common/follow';
describe('FollowComponent', () => {
describe('#getTargetElements', () => {
beforeEach(() => {
document.body.innerHTML = __html__['test/content/components/follow.html'];
document.body.innerHTML = __html__['test/content/components/common/follow.html'];
});
it('returns visible links', () => {
let targets = FollowComponent.getTargetElements(window.document);
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);

@ -1,9 +1,9 @@
import { expect } from "chai";
import Hint from 'content/hint';
import Hint from 'content/components/common/hint';
describe('Hint class', () => {
beforeEach(() => {
document.body.innerHTML = __html__['test/content/hint.html'];
document.body.innerHTML = __html__['test/content/components/common/hint.html'];
});
describe('#constructor', () => {