Keymaps as a clean architecture [WIP]
This commit is contained in:
parent
a88324acd9
commit
efc48dc742
15 changed files with 620 additions and 88 deletions
|
@ -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 ||
|
11
src/content/client/BackgroundClient.ts
Normal file
11
src/content/client/BackgroundClient.ts
Normal file
|
@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
23
src/content/client/FindMasterClient.ts
Normal file
23
src/content/client/FindMasterClient.ts
Normal file
|
@ -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);
|
||||||
|
|
139
src/content/controllers/KeymapController.ts
Normal file
139
src/content/controllers/KeymapController.ts
Normal file
|
@ -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();
|
||||||
|
|
25
src/content/presenters/FocusPresenter.ts
Normal file
25
src/content/presenters/FocusPresenter.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
23
src/content/repositories/KeymapRepository.ts
Normal file
23
src/content/repositories/KeymapRepository.ts
Normal file
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
20
src/content/usecases/FindSlaveUseCase.ts
Normal file
20
src/content/usecases/FindSlaveUseCase.ts
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
15
src/content/usecases/FocusUseCase.ts
Normal file
15
src/content/usecases/FocusUseCase.ts
Normal file
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
100
src/content/usecases/KeymapUseCase.ts
Normal file
100
src/content/usecases/KeymapUseCase.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
27
src/content/usecases/NavigateUseCase.ts
Normal file
27
src/content/usecases/NavigateUseCase.ts
Normal file
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
58
src/content/usecases/ScrollUseCase.ts
Normal file
58
src/content/usecases/ScrollUseCase.ts
Normal file
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
129
test/content/InputDriver.test.ts
Normal file
129
test/content/InputDriver.test.ts
Normal file
|
@ -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 a new issue