commit
33a97a0e8c
23 changed files with 462 additions and 240 deletions
@ -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), |
||||
}); |
||||
} |
@ -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,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,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', () => { |
Reference in new issue