Keymaps as a clean architecture [WIP]

jh-changes
Shin'ya Ueoka 6 years ago
parent a88324acd9
commit efc48dc742
  1. 16
      src/content/InputDriver.ts
  2. 11
      src/content/client/BackgroundClient.ts
  3. 23
      src/content/client/FindMasterClient.ts
  4. 4
      src/content/components/common/index.ts
  5. 139
      src/content/controllers/KeymapController.ts
  6. 46
      src/content/index.ts
  7. 25
      src/content/presenters/FocusPresenter.ts
  8. 23
      src/content/repositories/KeymapRepository.ts
  9. 20
      src/content/usecases/FindSlaveUseCase.ts
  10. 15
      src/content/usecases/FocusUseCase.ts
  11. 100
      src/content/usecases/KeymapUseCase.ts
  12. 27
      src/content/usecases/NavigateUseCase.ts
  13. 58
      src/content/usecases/ScrollUseCase.ts
  14. 129
      test/content/InputDriver.test.ts
  15. 72
      test/content/components/common/input.test.ts

@ -1,11 +1,11 @@
import * as dom from '../../../shared/utils/dom'; import * as dom from '../shared/utils/dom';
import * as keys from '../../../shared/utils/keys'; import * as keys from '../shared/utils/keys';
const cancelKey = (e: KeyboardEvent): boolean => { const cancelKey = (e: KeyboardEvent): boolean => {
return e.key === 'Escape' || e.key === '[' && e.ctrlKey; return e.key === 'Escape' || e.key === '[' && e.ctrlKey;
}; };
export default class InputComponent { export default class InputDriver {
private pressed: {[key: string]: string} = {}; private pressed: {[key: string]: string} = {};
private onKeyListeners: ((key: keys.Key) => boolean)[] = []; private onKeyListeners: ((key: keys.Key) => boolean)[] = [];
@ -23,7 +23,7 @@ export default class InputComponent {
this.onKeyListeners.push(cb); this.onKeyListeners.push(cb);
} }
onKeyPress(e: KeyboardEvent) { private onKeyPress(e: KeyboardEvent) {
if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') { if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') {
return; return;
} }
@ -31,7 +31,7 @@ export default class InputComponent {
this.capture(e); this.capture(e);
} }
onKeyDown(e: KeyboardEvent) { private onKeyDown(e: KeyboardEvent) {
if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') { if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') {
return; return;
} }
@ -39,12 +39,12 @@ export default class InputComponent {
this.capture(e); this.capture(e);
} }
onKeyUp(e: KeyboardEvent) { private onKeyUp(e: KeyboardEvent) {
delete this.pressed[e.key]; delete this.pressed[e.key];
} }
// eslint-disable-next-line max-statements // eslint-disable-next-line max-statements
capture(e: KeyboardEvent) { private capture(e: KeyboardEvent) {
let target = e.target; let target = e.target;
if (!(target instanceof HTMLElement)) { if (!(target instanceof HTMLElement)) {
return; return;
@ -71,7 +71,7 @@ export default class InputComponent {
} }
} }
fromInput(e: Element) { private fromInput(e: Element) {
return e instanceof HTMLInputElement || return e instanceof HTMLInputElement ||
e instanceof HTMLTextAreaElement || e instanceof HTMLTextAreaElement ||
e instanceof HTMLSelectElement || e instanceof HTMLSelectElement ||

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

@ -1,4 +1,4 @@
import InputComponent from './input'; import InputDriver from './../../InputDriver';
import FollowComponent from './follow'; import FollowComponent from './follow';
import MarkComponent from './mark'; import MarkComponent from './mark';
import KeymapperComponent from './keymapper'; import KeymapperComponent from './keymapper';
@ -15,7 +15,7 @@ let settingUseCase = new SettingUseCase();
export default class Common { export default class Common {
constructor(win: Window, store: any) { constructor(win: Window, store: any) {
const input = new InputComponent(win.document.body); const input = new InputDriver(win.document.body);
const follow = new FollowComponent(); const follow = new FollowComponent();
const mark = new MarkComponent(store); const mark = new MarkComponent(store);
const keymapper = new KeymapperComponent(store); const keymapper = new KeymapperComponent(store);

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

@ -1,15 +1,20 @@
import TopContentComponent from './components/top-content'; // import TopContentComponent from './components/top-content';
import FrameContentComponent from './components/frame-content'; // import FrameContentComponent from './components/frame-content';
import consoleFrameStyle from './site-style'; import consoleFrameStyle from './site-style';
import { newStore } from './store'; // import { newStore } from './store';
import MessageListener from './MessageListener'; import MessageListener from './MessageListener';
import FindController from './controllers/FindController'; import FindController from './controllers/FindController';
import * as messages from '../shared/messages'; import * as messages from '../shared/messages';
import InputDriver from './InputDriver';
import KeymapController from './controllers/KeymapController';
import AddonEnabledUseCase from './usecases/AddonEnabledUseCase';
import SettingUseCase from './usecases/SettingUseCase';
import * as blacklists from '../shared/blacklists';
const store = newStore(); // const store = newStore();
if (window.self === window.top) { if (window.self === window.top) {
new TopContentComponent(window, store); // eslint-disable-line no-new // new TopContentComponent(window, store); // eslint-disable-line no-new
let findController = new FindController(); let findController = new FindController();
new MessageListener().onWebMessage((message: messages.Message) => { new MessageListener().onWebMessage((message: messages.Message) => {
@ -24,9 +29,38 @@ if (window.self === window.top) {
return undefined; return undefined;
}); });
} else { } else {
new FrameContentComponent(window, store); // eslint-disable-line no-new // new FrameContentComponent(window, store); // eslint-disable-line no-new
} }
let keymapController = new KeymapController();
let inputDriver = new InputDriver(document.body);
// inputDriver.onKey(key => followSlaveController.pressKey(key));
// inputDriver.onKey(key => markController.pressKey(key));
inputDriver.onKey(key => keymapController.press(key));
let style = window.document.createElement('style'); let style = window.document.createElement('style');
style.textContent = consoleFrameStyle; style.textContent = consoleFrameStyle;
window.document.head.appendChild(style); window.document.head.appendChild(style);
// TODO move the following to a class
const reloadSettings = async() => {
let addonEnabledUseCase = new AddonEnabledUseCase();
let settingUseCase = new SettingUseCase();
try {
let current = await settingUseCase.reload();
let disabled = blacklists.includes(
current.blacklist, window.location.href,
);
if (disabled) {
addonEnabledUseCase.disable();
} else {
addonEnabledUseCase.enable();
}
} catch (e) {
// Sometime sendMessage fails when background script is not ready.
console.warn(e);
setTimeout(() => reloadSettings(), 500);
}
};
reloadSettings();

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