parent
a88324acd9
commit
efc48dc742
15 changed files with 620 additions and 88 deletions
@ -0,0 +1,11 @@ |
||||
import * as operations from '../../shared/operations'; |
||||
import * as messages from '../../shared/messages'; |
||||
|
||||
export default class BackgroundClient { |
||||
execBackgroundOp(op: operations.Operation): Promise<void> { |
||||
return browser.runtime.sendMessage({ |
||||
type: messages.BACKGROUND_OPERATION, |
||||
operation: op, |
||||
}); |
||||
} |
||||
} |
@ -0,0 +1,23 @@ |
||||
import * as messages from '../../shared/messages'; |
||||
|
||||
export default interface FindMasterClient { |
||||
findNext(): void; |
||||
|
||||
findPrev(): void; |
||||
|
||||
// eslint-disable-next-line semi
|
||||
} |
||||
|
||||
export class FindMasterClientImpl implements FindMasterClient { |
||||
findNext(): void { |
||||
window.top.postMessage(JSON.stringify({ |
||||
type: messages.FIND_NEXT, |
||||
}), '*'); |
||||
} |
||||
|
||||
findPrev(): void { |
||||
window.top.postMessage(JSON.stringify({ |
||||
type: messages.FIND_PREV, |
||||
}), '*'); |
||||
} |
||||
} |
@ -0,0 +1,139 @@ |
||||
import * as operations from '../../shared/operations'; |
||||
import KeymapUseCase from '../usecases/KeymapUseCase'; |
||||
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; |
||||
import FindSlaveUseCase from '../usecases/FindSlaveUseCase'; |
||||
import ScrollUseCase from '../usecases/ScrollUseCase'; |
||||
import NavigateUseCase from '../usecases/NavigateUseCase'; |
||||
import FocusUseCase from '../usecases/FocusUseCase'; |
||||
import ClipboardUseCase from '../usecases/ClipboardUseCase'; |
||||
import BackgroundClient from '../client/BackgroundClient'; |
||||
import { Key } from '../../shared/utils/keys'; |
||||
|
||||
export default class KeymapController { |
||||
private keymapUseCase: KeymapUseCase; |
||||
|
||||
private addonEnabledUseCase: AddonEnabledUseCase; |
||||
|
||||
private findSlaveUseCase: FindSlaveUseCase; |
||||
|
||||
private scrollUseCase: ScrollUseCase; |
||||
|
||||
private navigateUseCase: NavigateUseCase; |
||||
|
||||
private focusUseCase: FocusUseCase; |
||||
|
||||
private clipbaordUseCase: ClipboardUseCase; |
||||
|
||||
private backgroundClient: BackgroundClient; |
||||
|
||||
constructor({ |
||||
keymapUseCase = new KeymapUseCase(), |
||||
addonEnabledUseCase = new AddonEnabledUseCase(), |
||||
findSlaveUseCase = new FindSlaveUseCase(), |
||||
scrollUseCase = new ScrollUseCase(), |
||||
navigateUseCase = new NavigateUseCase(), |
||||
focusUseCase = new FocusUseCase(), |
||||
clipbaordUseCase = new ClipboardUseCase(), |
||||
backgroundClient = new BackgroundClient(), |
||||
} = {}) { |
||||
this.keymapUseCase = keymapUseCase; |
||||
this.addonEnabledUseCase = addonEnabledUseCase; |
||||
this.findSlaveUseCase = findSlaveUseCase; |
||||
this.scrollUseCase = scrollUseCase; |
||||
this.navigateUseCase = navigateUseCase; |
||||
this.focusUseCase = focusUseCase; |
||||
this.clipbaordUseCase = clipbaordUseCase; |
||||
this.backgroundClient = backgroundClient; |
||||
} |
||||
|
||||
// eslint-disable-next-line complexity, max-lines-per-function
|
||||
press(key: Key): boolean { |
||||
let op = this.keymapUseCase.nextOp(key); |
||||
if (op === null) { |
||||
return false; |
||||
} |
||||
|
||||
// do not await due to return a boolean immediately
|
||||
switch (op.type) { |
||||
case operations.ADDON_ENABLE: |
||||
this.addonEnabledUseCase.enable(); |
||||
break; |
||||
case operations.ADDON_DISABLE: |
||||
this.addonEnabledUseCase.disable(); |
||||
break; |
||||
case operations.ADDON_TOGGLE_ENABLED: |
||||
this.addonEnabledUseCase.toggle(); |
||||
break; |
||||
case operations.FIND_NEXT: |
||||
this.findSlaveUseCase.findNext(); |
||||
break; |
||||
case operations.FIND_PREV: |
||||
this.findSlaveUseCase.findPrev(); |
||||
break; |
||||
case operations.SCROLL_VERTICALLY: |
||||
this.scrollUseCase.scrollVertically(op.count); |
||||
break; |
||||
case operations.SCROLL_HORIZONALLY: |
||||
this.scrollUseCase.scrollHorizonally(op.count); |
||||
break; |
||||
case operations.SCROLL_PAGES: |
||||
this.scrollUseCase.scrollPages(op.count); |
||||
break; |
||||
case operations.SCROLL_TOP: |
||||
this.scrollUseCase.scrollToTop(); |
||||
break; |
||||
case operations.SCROLL_BOTTOM: |
||||
this.scrollUseCase.scrollToBottom(); |
||||
break; |
||||
case operations.SCROLL_HOME: |
||||
this.scrollUseCase.scrollToHome(); |
||||
break; |
||||
case operations.SCROLL_END: |
||||
this.scrollUseCase.scrollToEnd(); |
||||
break; |
||||
// case operations.FOLLOW_START:
|
||||
// window.top.postMessage(JSON.stringify({
|
||||
// type: messages.FOLLOW_START,
|
||||
// newTab: operation.newTab,
|
||||
// background: operation.background,
|
||||
// }), '*');
|
||||
// break;
|
||||
// case operations.MARK_SET_PREFIX:
|
||||
// return markActions.startSet();
|
||||
// case operations.MARK_JUMP_PREFIX:
|
||||
// return markActions.startJump();
|
||||
case operations.NAVIGATE_HISTORY_PREV: |
||||
this.navigateUseCase.openHistoryPrev(); |
||||
break; |
||||
case operations.NAVIGATE_HISTORY_NEXT: |
||||
this.navigateUseCase.openHistoryNext(); |
||||
break; |
||||
case operations.NAVIGATE_LINK_PREV: |
||||
this.navigateUseCase.openLinkPrev(); |
||||
break; |
||||
case operations.NAVIGATE_LINK_NEXT: |
||||
this.navigateUseCase.openLinkNext(); |
||||
break; |
||||
case operations.NAVIGATE_PARENT: |
||||
this.navigateUseCase.openParent(); |
||||
break; |
||||
case operations.NAVIGATE_ROOT: |
||||
this.navigateUseCase.openRoot(); |
||||
break; |
||||
case operations.FOCUS_INPUT: |
||||
this.focusUseCase.focusFirstInput(); |
||||
break; |
||||
case operations.URLS_YANK: |
||||
this.clipbaordUseCase.yankCurrentURL(); |
||||
break; |
||||
case operations.URLS_PASTE: |
||||
this.clipbaordUseCase.openOrSearch( |
||||
op.newTab ? op.newTab : false, |
||||
); |
||||
break; |
||||
default: |
||||
this.backgroundClient.execBackgroundOp(op); |
||||
} |
||||
return true; |
||||
} |
||||
} |
@ -0,0 +1,25 @@ |
||||
import * as doms from '../../shared/utils/dom'; |
||||
|
||||
export default interface FocusPresenter { |
||||
focusFirstElement(): boolean; |
||||
|
||||
// eslint-disable-next-line semi
|
||||
} |
||||
|
||||
export class FocusPresenterImpl implements FocusPresenter { |
||||
focusFirstElement(): boolean { |
||||
let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url']; |
||||
let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(','); |
||||
let targets = window.document.querySelectorAll(inputSelector + ',textarea'); |
||||
let target = Array.from(targets).find(doms.isVisible); |
||||
if (target instanceof HTMLInputElement) { |
||||
target.focus(); |
||||
return true; |
||||
} else if (target instanceof HTMLTextAreaElement) { |
||||
target.focus(); |
||||
return true; |
||||
} |
||||
return false; |
||||
} |
||||
} |
||||
|
@ -0,0 +1,23 @@ |
||||
import { Key } from '../../shared/utils/keys'; |
||||
|
||||
export default interface KeymapRepository { |
||||
enqueueKey(key: Key): Key[]; |
||||
|
||||
clear(): void; |
||||
|
||||
// eslint-disable-next-line semi
|
||||
} |
||||
|
||||
let current: Key[] = []; |
||||
|
||||
export class KeymapRepositoryImpl { |
||||
|
||||
enqueueKey(key: Key): Key[] { |
||||
current.push(key); |
||||
return current; |
||||
} |
||||
|
||||
clear(): void { |
||||
current = []; |
||||
} |
||||
} |
@ -0,0 +1,20 @@ |
||||
import FindMasterClient, { FindMasterClientImpl } |
||||
from '../client/FindMasterClient'; |
||||
|
||||
export default class FindSlaveUseCase { |
||||
private findMasterClient: FindMasterClient; |
||||
|
||||
constructor({ |
||||
findMasterClient = new FindMasterClientImpl(), |
||||
} = {}) { |
||||
this.findMasterClient = findMasterClient; |
||||
} |
||||
|
||||
findNext() { |
||||
this.findMasterClient.findNext(); |
||||
} |
||||
|
||||
findPrev() { |
||||
this.findMasterClient.findPrev(); |
||||
} |
||||
} |
@ -0,0 +1,15 @@ |
||||
import FocusPresenter, { FocusPresenterImpl } |
||||
from '../presenters/FocusPresenter'; |
||||
export default class FocusUseCases { |
||||
private presenter: FocusPresenter; |
||||
|
||||
constructor({ |
||||
presenter = new FocusPresenterImpl(), |
||||
} = {}) { |
||||
this.presenter = presenter; |
||||
} |
||||
|
||||
focusFirstInput() { |
||||
this.presenter.focusFirstElement(); |
||||
} |
||||
} |
@ -0,0 +1,100 @@ |
||||
import KeymapRepository, { KeymapRepositoryImpl } |
||||
from '../repositories/KeymapRepository'; |
||||
import SettingRepository, { SettingRepositoryImpl } |
||||
from '../repositories/SettingRepository'; |
||||
import AddonEnabledRepository, { AddonEnabledRepositoryImpl } |
||||
from '../repositories/AddonEnabledRepository'; |
||||
|
||||
import * as operations from '../../shared/operations'; |
||||
import { Keymaps } from '../../shared/Settings'; |
||||
import * as keyUtils from '../../shared/utils/keys'; |
||||
|
||||
type KeymapEntityMap = Map<keyUtils.Key[], operations.Operation>; |
||||
|
||||
const reservedKeymaps: Keymaps = { |
||||
'<Esc>': { type: operations.CANCEL }, |
||||
'<C-[>': { type: operations.CANCEL }, |
||||
}; |
||||
|
||||
const mapStartsWith = ( |
||||
mapping: keyUtils.Key[], |
||||
keys: keyUtils.Key[], |
||||
): boolean => { |
||||
if (mapping.length < keys.length) { |
||||
return false; |
||||
} |
||||
for (let i = 0; i < keys.length; ++i) { |
||||
if (!keyUtils.equals(mapping[i], keys[i])) { |
||||
return false; |
||||
} |
||||
} |
||||
return true; |
||||
}; |
||||
|
||||
export default class KeymapUseCase { |
||||
private repository: KeymapRepository; |
||||
|
||||
private settingRepository: SettingRepository; |
||||
|
||||
private addonEnabledRepository: AddonEnabledRepository; |
||||
|
||||
constructor({ |
||||
repository = new KeymapRepositoryImpl(), |
||||
settingRepository = new SettingRepositoryImpl(), |
||||
addonEnabledRepository = new AddonEnabledRepositoryImpl(), |
||||
} = {}) { |
||||
this.repository = repository; |
||||
this.settingRepository = settingRepository; |
||||
this.addonEnabledRepository = addonEnabledRepository; |
||||
} |
||||
|
||||
nextOp(key: keyUtils.Key): operations.Operation | null { |
||||
let keys = this.repository.enqueueKey(key); |
||||
|
||||
let keymaps = this.keymapEntityMap(); |
||||
let matched = Array.from(keymaps.keys()).filter( |
||||
(mapping: keyUtils.Key[]) => { |
||||
return mapStartsWith(mapping, keys); |
||||
}); |
||||
if (!this.addonEnabledRepository.get()) { |
||||
// available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if
|
||||
// the addon disabled
|
||||
matched = matched.filter((keymap) => { |
||||
let type = (keymaps.get(keymap) as operations.Operation).type; |
||||
return type === operations.ADDON_ENABLE || |
||||
type === operations.ADDON_TOGGLE_ENABLED; |
||||
}); |
||||
} |
||||
if (matched.length === 0) { |
||||
// No operations to match with inputs
|
||||
this.repository.clear(); |
||||
return null; |
||||
} else if (matched.length > 1 || |
||||
matched.length === 1 && keys.length < matched[0].length) { |
||||
// More than one operations are matched
|
||||
return null; |
||||
} |
||||
// Exactly one operation is matched
|
||||
let operation = keymaps.get(matched[0]) as operations.Operation; |
||||
this.repository.clear(); |
||||
return operation; |
||||
} |
||||
|
||||
clear(): void { |
||||
this.repository.clear(); |
||||
} |
||||
|
||||
private keymapEntityMap(): KeymapEntityMap { |
||||
let keymaps = { |
||||
...this.settingRepository.get().keymaps, |
||||
...reservedKeymaps, |
||||
}; |
||||
let entries = Object.entries(keymaps).map((entry) => { |
||||
return [ |
||||
keyUtils.fromMapKeys(entry[0]), |
||||
entry[1], |
||||
]; |
||||
}) as [keyUtils.Key[], operations.Operation][]; |
||||
return new Map<keyUtils.Key[], operations.Operation>(entries); |
||||
} |
||||
} |
@ -0,0 +1,27 @@ |
||||
import * as navigates from '../navigates'; |
||||
|
||||
export default class NavigateClass { |
||||
openHistoryPrev(): void { |
||||
navigates.historyPrev(window); |
||||
} |
||||
|
||||
openHistoryNext(): void { |
||||
navigates.historyNext(window); |
||||
} |
||||
|
||||
openLinkPrev(): void { |
||||
navigates.linkPrev(window); |
||||
} |
||||
|
||||
openLinkNext(): void { |
||||
navigates.linkNext(window); |
||||
} |
||||
|
||||
openParent(): void { |
||||
navigates.parent(window); |
||||
} |
||||
|
||||
openRoot(): void { |
||||
navigates.root(window); |
||||
} |
||||
} |
@ -0,0 +1,58 @@ |
||||
import ScrollPresenter, { ScrollPresenterImpl } |
||||
from '../presenters/ScrollPresenter'; |
||||
import SettingRepository, { SettingRepositoryImpl } |
||||
from '../repositories/SettingRepository'; |
||||
|
||||
export default class ScrollUseCase { |
||||
private presenter: ScrollPresenter; |
||||
|
||||
private settingRepository: SettingRepository; |
||||
|
||||
constructor({ |
||||
presenter = new ScrollPresenterImpl(), |
||||
settingRepository = new SettingRepositoryImpl(), |
||||
} = {}) { |
||||
this.presenter = presenter; |
||||
this.settingRepository = settingRepository; |
||||
} |
||||
|
||||
scrollVertically(count: number): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollVertically(count, smooth); |
||||
} |
||||
|
||||
scrollHorizonally(count: number): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollHorizonally(count, smooth); |
||||
} |
||||
|
||||
scrollPages(count: number): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollPages(count, smooth); |
||||
} |
||||
|
||||
scrollToTop(): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollToTop(smooth); |
||||
} |
||||
|
||||
scrollToBottom(): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollToBottom(smooth); |
||||
} |
||||
|
||||
scrollToHome(): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollToHome(smooth); |
||||
} |
||||
|
||||
scrollToEnd(): void { |
||||
let smooth = this.getSmoothScroll(); |
||||
this.presenter.scrollToEnd(smooth); |
||||
} |
||||
|
||||
private getSmoothScroll(): boolean { |
||||
let settings = this.settingRepository.get(); |
||||
return settings.properties.smoothscroll; |
||||
} |
||||
} |
@ -0,0 +1,129 @@ |
||||
import InputDriver from '../../src/content/InputDriver'; |
||||
import { expect } from 'chai'; |
||||
import { Key } from '../../src/shared/utils/keys'; |
||||
|
||||
describe('InputDriver', () => { |
||||
let target: HTMLElement; |
||||
let driver: InputDriver; |
||||
|
||||
beforeEach(() => { |
||||
target = document.createElement('div'); |
||||
document.body.appendChild(target); |
||||
driver = new InputDriver(target); |
||||
}); |
||||
|
||||
afterEach(() => { |
||||
target.remove(); |
||||
target = null; |
||||
driver = null; |
||||
}); |
||||
|
||||
it('register callbacks', (done) => { |
||||
driver.onKey((key: Key): boolean => { |
||||
expect(key.key).to.equal('a'); |
||||
expect(key.ctrlKey).to.be.true; |
||||
expect(key.shiftKey).to.be.false; |
||||
expect(key.altKey).to.be.false; |
||||
expect(key.metaKey).to.be.false; |
||||
done(); |
||||
return true; |
||||
}); |
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { |
||||
key: 'a', |
||||
ctrlKey: true, |
||||
shiftKey: false, |
||||
altKey: false, |
||||
metaKey: false, |
||||
})); |
||||
}); |
||||
|
||||
it('invoke callback once', () => { |
||||
let a = 0, b = 0; |
||||
driver.onKey((key: Key): boolean => { |
||||
if (key.key == 'a') { |
||||
++a; |
||||
} else { |
||||
key.key == 'b' |
||||
++b; |
||||
} |
||||
return true; |
||||
}); |
||||
|
||||
let events = [ |
||||
new KeyboardEvent('keydown', { key: 'a' }), |
||||
new KeyboardEvent('keydown', { key: 'b' }), |
||||
new KeyboardEvent('keypress', { key: 'a' }), |
||||
new KeyboardEvent('keyup', { key: 'a' }), |
||||
new KeyboardEvent('keypress', { key: 'b' }), |
||||
new KeyboardEvent('keyup', { key: 'b' }), |
||||
]; |
||||
for (let e of events) { |
||||
target.dispatchEvent(e); |
||||
} |
||||
|
||||
expect(a).to.equal(1); |
||||
expect(b).to.equal(1); |
||||
}) |
||||
|
||||
it('propagates and stop handler chain', () => { |
||||
let a = 0, b = 0, c = 0; |
||||
driver.onKey((key: Key): boolean => { |
||||
a++; |
||||
return false; |
||||
}); |
||||
driver.onKey((key: Key): boolean => { |
||||
b++; |
||||
return true; |
||||
}); |
||||
driver.onKey((key: Key): boolean => { |
||||
c++; |
||||
return true; |
||||
}); |
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' })); |
||||
|
||||
expect(a).to.equal(1); |
||||
expect(b).to.equal(1); |
||||
expect(c).to.equal(0); |
||||
}) |
||||
|
||||
it('does not invoke only meta keys', () => { |
||||
driver.onKey((key: Key): boolean=> { |
||||
expect.fail(); |
||||
return false; |
||||
}); |
||||
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })); |
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' })); |
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })); |
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'OS' })); |
||||
}) |
||||
|
||||
it('ignores events from input elements', () => { |
||||
['input', 'textarea', 'select'].forEach((name) => { |
||||
let input = window.document.createElement(name); |
||||
let driver = new InputDriver(input); |
||||
driver.onKey((key: Key): boolean => { |
||||
expect.fail(); |
||||
return false; |
||||
}); |
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); |
||||
}); |
||||
}); |
||||
|
||||
it('ignores events from contenteditable elements', () => { |
||||
let div = window.document.createElement('div'); |
||||
let driver = new InputDriver(div); |
||||
driver.onKey((key: Key): boolean => { |
||||
expect.fail(); |
||||
return false; |
||||
}); |
||||
|
||||
div.setAttribute('contenteditable', ''); |
||||
div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); |
||||
|
||||
div.setAttribute('contenteditable', 'true'); |
||||
div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); |
||||
}); |
||||
}); |
@ -1,72 +0,0 @@ |
||||
import InputComponent from 'content/components/common/input'; |
||||
|
||||
describe('InputComponent', () => { |
||||
it('register callbacks', () => { |
||||
let component = new InputComponent(window.document); |
||||
let key = { key: 'a', ctrlKey: true, shiftKey: false, altKey: false, metaKey: false }; |
||||
component.onKey((key) => { |
||||
expect(key).to.deep.equal(key); |
||||
}); |
||||
component.onKeyDown(key); |
||||
}); |
||||
|
||||
it('invoke callback once', () => { |
||||
let component = new InputComponent(window.document); |
||||
let a = 0, b = 0; |
||||
component.onKey((key) => { |
||||
if (key.key == 'a') { |
||||
++a; |
||||
} else { |
||||
key.key == 'b' |
||||
++b; |
||||
} |
||||
}); |
||||
|
||||
let elem = document.body; |
||||
component.onKeyDown({ key: 'a', target: elem }); |
||||
component.onKeyDown({ key: 'b', target: elem }); |
||||
component.onKeyPress({ key: 'a', target: elem }); |
||||
component.onKeyUp({ key: 'a', target: elem }); |
||||
component.onKeyPress({ key: 'b', target: elem }); |
||||
component.onKeyUp({ key: 'b', target: elem }); |
||||
|
||||
expect(a).is.equals(1); |
||||
expect(b).is.equals(1); |
||||
}) |
||||
|
||||
it('does not invoke only meta keys', () => { |
||||
let component = new InputComponent(window.document); |
||||
component.onKey((key) => { |
||||
expect.fail(); |
||||
}); |
||||
component.onKeyDown({ key: 'Shift' }); |
||||
component.onKeyDown({ key: 'Control' }); |
||||
component.onKeyDown({ key: 'Alt' }); |
||||
component.onKeyDown({ key: 'OS' }); |
||||
}) |
||||
|
||||
it('ignores events from input elements', () => { |
||||
['input', 'textarea', 'select'].forEach((name) => { |
||||
let target = window.document.createElement(name); |
||||
let component = new InputComponent(target); |
||||
component.onKey((key) => { |
||||
expect.fail(); |
||||
}); |
||||
component.onKeyDown({ key: 'x', target }); |
||||
}); |
||||
}); |
||||
|
||||
it('ignores events from contenteditable elements', () => { |
||||
let target = window.document.createElement('div'); |
||||
let component = new InputComponent(target); |
||||
component.onKey((key) => { |
||||
expect.fail(); |
||||
}); |
||||
|
||||
target.setAttribute('contenteditable', ''); |
||||
component.onKeyDown({ key: 'x', target }); |
||||
|
||||
target.setAttribute('contenteditable', 'true'); |
||||
component.onKeyDown({ key: 'x', target }); |
||||
}) |
||||
}); |
Reference in new issue