diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2019-05-11 19:43:56 +0900 |
---|---|---|
committer | Shin'ya Ueoka <ueokande@i-beam.org> | 2019-05-18 17:28:11 +0900 |
commit | efc48dc7421e3bd48534bc94f84e2b0bd47ae47c (patch) | |
tree | dfe80ebc368911c385e6c36aa1096af619b1616b /src | |
parent | a88324acd9fe626b59637541975abe1ee6041aa7 (diff) |
Keymaps as a clean architecture [WIP]
Diffstat (limited to 'src')
-rw-r--r-- | src/content/InputDriver.ts (renamed from src/content/components/common/input.ts) | 16 | ||||
-rw-r--r-- | src/content/client/BackgroundClient.ts | 11 | ||||
-rw-r--r-- | src/content/client/FindMasterClient.ts | 23 | ||||
-rw-r--r-- | src/content/components/common/index.ts | 4 | ||||
-rw-r--r-- | src/content/controllers/KeymapController.ts | 139 | ||||
-rw-r--r-- | src/content/index.ts | 46 | ||||
-rw-r--r-- | src/content/presenters/FocusPresenter.ts | 25 | ||||
-rw-r--r-- | src/content/repositories/KeymapRepository.ts | 23 | ||||
-rw-r--r-- | src/content/usecases/FindSlaveUseCase.ts | 20 | ||||
-rw-r--r-- | src/content/usecases/FocusUseCase.ts | 15 | ||||
-rw-r--r-- | src/content/usecases/KeymapUseCase.ts | 100 | ||||
-rw-r--r-- | src/content/usecases/NavigateUseCase.ts | 27 | ||||
-rw-r--r-- | src/content/usecases/ScrollUseCase.ts | 58 |
13 files changed, 491 insertions, 16 deletions
diff --git a/src/content/components/common/input.ts b/src/content/InputDriver.ts index 1fe34c9..09648c1 100644 --- a/src/content/components/common/input.ts +++ b/src/content/InputDriver.ts @@ -1,11 +1,11 @@ -import * as dom from '../../../shared/utils/dom'; -import * as keys from '../../../shared/utils/keys'; +import * as dom from '../shared/utils/dom'; +import * as keys from '../shared/utils/keys'; const cancelKey = (e: KeyboardEvent): boolean => { return e.key === 'Escape' || e.key === '[' && e.ctrlKey; }; -export default class InputComponent { +export default class InputDriver { private pressed: {[key: string]: string} = {}; private onKeyListeners: ((key: keys.Key) => boolean)[] = []; @@ -23,7 +23,7 @@ export default class InputComponent { this.onKeyListeners.push(cb); } - onKeyPress(e: KeyboardEvent) { + private onKeyPress(e: KeyboardEvent) { if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') { return; } @@ -31,7 +31,7 @@ export default class InputComponent { this.capture(e); } - onKeyDown(e: KeyboardEvent) { + private onKeyDown(e: KeyboardEvent) { if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') { return; } @@ -39,12 +39,12 @@ export default class InputComponent { this.capture(e); } - onKeyUp(e: KeyboardEvent) { + private onKeyUp(e: KeyboardEvent) { delete this.pressed[e.key]; } // eslint-disable-next-line max-statements - capture(e: KeyboardEvent) { + private capture(e: KeyboardEvent) { let target = e.target; if (!(target instanceof HTMLElement)) { return; @@ -71,7 +71,7 @@ export default class InputComponent { } } - fromInput(e: Element) { + private fromInput(e: Element) { return e instanceof HTMLInputElement || e instanceof HTMLTextAreaElement || e instanceof HTMLSelectElement || diff --git a/src/content/client/BackgroundClient.ts b/src/content/client/BackgroundClient.ts new file mode 100644 index 0000000..2fe8d01 --- /dev/null +++ b/src/content/client/BackgroundClient.ts @@ -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, + }); + } +} diff --git a/src/content/client/FindMasterClient.ts b/src/content/client/FindMasterClient.ts new file mode 100644 index 0000000..0481ec1 --- /dev/null +++ b/src/content/client/FindMasterClient.ts @@ -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, + }), '*'); + } +} diff --git a/src/content/components/common/index.ts b/src/content/components/common/index.ts index b2f48a3..c74020e 100644 --- a/src/content/components/common/index.ts +++ b/src/content/components/common/index.ts @@ -1,4 +1,4 @@ -import InputComponent from './input'; +import InputDriver from './../../InputDriver'; import FollowComponent from './follow'; import MarkComponent from './mark'; import KeymapperComponent from './keymapper'; @@ -15,7 +15,7 @@ let settingUseCase = new SettingUseCase(); export default class Common { constructor(win: Window, store: any) { - const input = new InputComponent(win.document.body); + const input = new InputDriver(win.document.body); const follow = new FollowComponent(); const mark = new MarkComponent(store); const keymapper = new KeymapperComponent(store); diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts new file mode 100644 index 0000000..09e5b0c --- /dev/null +++ b/src/content/controllers/KeymapController.ts @@ -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; + } +} diff --git a/src/content/index.ts b/src/content/index.ts index 4024b98..f983f9f 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,15 +1,20 @@ -import TopContentComponent from './components/top-content'; -import FrameContentComponent from './components/frame-content'; +// import TopContentComponent from './components/top-content'; +// import FrameContentComponent from './components/frame-content'; import consoleFrameStyle from './site-style'; -import { newStore } from './store'; +// import { newStore } from './store'; import MessageListener from './MessageListener'; import FindController from './controllers/FindController'; 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) { - new TopContentComponent(window, store); // eslint-disable-line no-new + // new TopContentComponent(window, store); // eslint-disable-line no-new let findController = new FindController(); new MessageListener().onWebMessage((message: messages.Message) => { @@ -24,9 +29,38 @@ if (window.self === window.top) { return undefined; }); } 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'); style.textContent = consoleFrameStyle; 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(); diff --git a/src/content/presenters/FocusPresenter.ts b/src/content/presenters/FocusPresenter.ts new file mode 100644 index 0000000..4cef5bf --- /dev/null +++ b/src/content/presenters/FocusPresenter.ts @@ -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; + } +} + diff --git a/src/content/repositories/KeymapRepository.ts b/src/content/repositories/KeymapRepository.ts new file mode 100644 index 0000000..081cc54 --- /dev/null +++ b/src/content/repositories/KeymapRepository.ts @@ -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 = []; + } +} diff --git a/src/content/usecases/FindSlaveUseCase.ts b/src/content/usecases/FindSlaveUseCase.ts new file mode 100644 index 0000000..b733cbd --- /dev/null +++ b/src/content/usecases/FindSlaveUseCase.ts @@ -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(); + } +} diff --git a/src/content/usecases/FocusUseCase.ts b/src/content/usecases/FocusUseCase.ts new file mode 100644 index 0000000..615442d --- /dev/null +++ b/src/content/usecases/FocusUseCase.ts @@ -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(); + } +} diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts new file mode 100644 index 0000000..a4f9c36 --- /dev/null +++ b/src/content/usecases/KeymapUseCase.ts @@ -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); + } +} diff --git a/src/content/usecases/NavigateUseCase.ts b/src/content/usecases/NavigateUseCase.ts new file mode 100644 index 0000000..f790212 --- /dev/null +++ b/src/content/usecases/NavigateUseCase.ts @@ -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); + } +} diff --git a/src/content/usecases/ScrollUseCase.ts b/src/content/usecases/ScrollUseCase.ts new file mode 100644 index 0000000..6a1f801 --- /dev/null +++ b/src/content/usecases/ScrollUseCase.ts @@ -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; + } +} |