From e0c4182f14f908d13c8c814c7bc2b48a1791f881 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 19 May 2019 09:26:52 +0900 Subject: [PATCH] Follow as a clean architecture --- .../controllers/FollowKeyController.ts | 21 +++ .../controllers/FollowMasterController.ts | 31 ++++ .../controllers/FollowSlaveController.ts | 32 ++++ src/content/controllers/KeymapController.ts | 16 +- src/content/index.ts | 35 +++- .../repositories/FollowKeyRepository.ts | 35 ++++ .../repositories/FollowMasterRepository.ts | 59 +++++++ .../repositories/FollowSlaveRepository.ts | 31 ++++ src/content/usecases/FollowMasterUseCase.ts | 150 ++++++++++++++++++ src/content/usecases/FollowSlaveUseCase.ts | 91 +++++++++++ test/content/InputDriver.test.ts | 2 +- 11 files changed, 493 insertions(+), 10 deletions(-) create mode 100644 src/content/controllers/FollowKeyController.ts create mode 100644 src/content/controllers/FollowMasterController.ts create mode 100644 src/content/controllers/FollowSlaveController.ts create mode 100644 src/content/repositories/FollowKeyRepository.ts create mode 100644 src/content/repositories/FollowMasterRepository.ts create mode 100644 src/content/repositories/FollowSlaveRepository.ts create mode 100644 src/content/usecases/FollowMasterUseCase.ts create mode 100644 src/content/usecases/FollowSlaveUseCase.ts diff --git a/src/content/controllers/FollowKeyController.ts b/src/content/controllers/FollowKeyController.ts new file mode 100644 index 0000000..eb45e01 --- /dev/null +++ b/src/content/controllers/FollowKeyController.ts @@ -0,0 +1,21 @@ +import FollowSlaveUseCase from '../usecases/FollowSlaveUseCase'; +import Key from '../domains/Key'; + +export default class FollowKeyController { + private followSlaveUseCase: FollowSlaveUseCase; + + constructor({ + followSlaveUseCase = new FollowSlaveUseCase(), + } = {}) { + this.followSlaveUseCase = followSlaveUseCase; + } + + press(key: Key): boolean { + if (!this.followSlaveUseCase.isFollowMode()) { + return false; + } + + this.followSlaveUseCase.sendKey(key); + return true; + } +} diff --git a/src/content/controllers/FollowMasterController.ts b/src/content/controllers/FollowMasterController.ts new file mode 100644 index 0000000..89294ff --- /dev/null +++ b/src/content/controllers/FollowMasterController.ts @@ -0,0 +1,31 @@ +import FollowMasterUseCase from '../usecases/FollowMasterUseCase'; +import * as messages from '../../shared/messages'; + +export default class FollowMasterController { + private followMasterUseCase: FollowMasterUseCase; + + constructor({ + followMasterUseCase = new FollowMasterUseCase(), + } = {}) { + this.followMasterUseCase = followMasterUseCase; + } + + followStart(m: messages.FollowStartMessage): void { + this.followMasterUseCase.startFollow(m.newTab, m.background); + } + + responseCountTargets( + m: messages.FollowResponseCountTargetsMessage, sender: Window, + ): void { + this.followMasterUseCase.createSlaveHints(m.count, sender); + } + + keyPress(message: messages.FollowKeyPressMessage): void { + if (message.key === '[' && message.ctrlKey) { + this.followMasterUseCase.cancelFollow(); + } else { + this.followMasterUseCase.enqueue(message.key); + } + } +} + diff --git a/src/content/controllers/FollowSlaveController.ts b/src/content/controllers/FollowSlaveController.ts new file mode 100644 index 0000000..88dccf3 --- /dev/null +++ b/src/content/controllers/FollowSlaveController.ts @@ -0,0 +1,32 @@ +import * as messages from '../../shared/messages'; +import FollowSlaveUseCase from '../usecases/FollowSlaveUseCase'; + +export default class FollowSlaveController { + private usecase: FollowSlaveUseCase; + + constructor({ + usecase = new FollowSlaveUseCase(), + } = {}) { + this.usecase = usecase; + } + + countTargets(m: messages.FollowRequestCountTargetsMessage): void { + this.usecase.countTargets(m.viewSize, m.framePosition); + } + + createHints(m: messages.FollowCreateHintsMessage): void { + this.usecase.createHints(m.viewSize, m.framePosition, m.tags); + } + + showHints(m: messages.FollowShowHintsMessage): void { + this.usecase.showHints(m.prefix); + } + + activate(m: messages.FollowActivateMessage): void { + this.usecase.activate(m.tag, m.newTab, m.background); + } + + clear(_m: messages.FollowRemoveHintsMessage) { + this.usecase.clear(); + } +} diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts index 424292c..20c24c0 100644 --- a/src/content/controllers/KeymapController.ts +++ b/src/content/controllers/KeymapController.ts @@ -8,6 +8,8 @@ import FocusUseCase from '../usecases/FocusUseCase'; import ClipboardUseCase from '../usecases/ClipboardUseCase'; import BackgroundClient from '../client/BackgroundClient'; import MarkKeyyUseCase from '../usecases/MarkKeyUseCase'; +import FollowMasterClient, { FollowMasterClientImpl } + from '../client/FollowMasterClient'; import Key from '../domains/Key'; export default class KeymapController { @@ -29,6 +31,8 @@ export default class KeymapController { private markKeyUseCase: MarkKeyyUseCase; + private followMasterClient: FollowMasterClient; + constructor({ keymapUseCase = new KeymapUseCase(), addonEnabledUseCase = new AddonEnabledUseCase(), @@ -39,6 +43,7 @@ export default class KeymapController { clipbaordUseCase = new ClipboardUseCase(), backgroundClient = new BackgroundClient(), markKeyUseCase = new MarkKeyyUseCase(), + followMasterClient = new FollowMasterClientImpl(window.top), } = {}) { this.keymapUseCase = keymapUseCase; this.addonEnabledUseCase = addonEnabledUseCase; @@ -49,6 +54,7 @@ export default class KeymapController { this.clipbaordUseCase = clipbaordUseCase; this.backgroundClient = backgroundClient; this.markKeyUseCase = markKeyUseCase; + this.followMasterClient = followMasterClient; } // eslint-disable-next-line complexity, max-lines-per-function @@ -96,13 +102,9 @@ export default class KeymapController { 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.FOLLOW_START: + this.followMasterClient.startFollow(op.newTab, op.background); + break; case operations.MARK_SET_PREFIX: this.markKeyUseCase.enableSetMode(); break; diff --git a/src/content/index.ts b/src/content/index.ts index d644095..08bdf6b 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -6,6 +6,9 @@ import consoleFrameStyle from './site-style'; import MessageListener from './MessageListener'; import FindController from './controllers/FindController'; import MarkController from './controllers/MarkController'; +import FollowMasterController from './controllers/FollowMasterController'; +import FollowSlaveController from './controllers/FollowSlaveController'; +import FollowKeyController from './controllers/FollowKeyController'; import * as messages from '../shared/messages'; import InputDriver from './InputDriver'; import KeymapController from './controllers/KeymapController'; @@ -17,11 +20,14 @@ import AddonEnabledController from './controllers/AddonEnabledController'; // const store = newStore(); +let listener = new MessageListener(); if (window.self === window.top) { // new TopContentComponent(window, store); // eslint-disable-line no-new let findController = new FindController(); - new MessageListener().onWebMessage((message: messages.Message) => { + + let followMasterController = new FollowMasterController(); + listener.onWebMessage((message: messages.Message, sender: Window) => { switch (message.type) { case messages.CONSOLE_ENTER_FIND: return findController.start(message); @@ -32,6 +38,13 @@ if (window.self === window.top) { case messages.CONSOLE_UNFOCUS: window.focus(); consoleFrames.blur(window.document); + break; + case messages.FOLLOW_START: + return followMasterController.followStart(message); + case messages.FOLLOW_RESPONSE_COUNT_TARGETS: + return followMasterController.responseCountTargets(message, sender); + case messages.FOLLOW_KEY_PRESS: + return followMasterController.keyPress(message); } return undefined; }); @@ -54,10 +67,28 @@ if (window.self === window.top) { // new FrameContentComponent(window, store); // eslint-disable-line no-new } +let followSlaveController = new FollowSlaveController(); +listener.onWebMessage((message: messages.Message) => { + switch (message.type) { + case messages.FOLLOW_REQUEST_COUNT_TARGETS: + return followSlaveController.countTargets(message); + case messages.FOLLOW_CREATE_HINTS: + return followSlaveController.createHints(message); + case messages.FOLLOW_SHOW_HINTS: + return followSlaveController.showHints(message); + case messages.FOLLOW_ACTIVATE: + return followSlaveController.activate(message); + case messages.FOLLOW_REMOVE_HINTS: + return followSlaveController.clear(message); + } + return undefined; +}); + let keymapController = new KeymapController(); let markKeyController = new MarkKeyController(); +let followKeyController = new FollowKeyController(); let inputDriver = new InputDriver(document.body); -// inputDriver.onKey(key => followSlaveController.pressKey(key)); +inputDriver.onKey(key => followKeyController.press(key)); inputDriver.onKey(key => markKeyController.press(key)); inputDriver.onKey(key => keymapController.press(key)); diff --git a/src/content/repositories/FollowKeyRepository.ts b/src/content/repositories/FollowKeyRepository.ts new file mode 100644 index 0000000..a671b5c --- /dev/null +++ b/src/content/repositories/FollowKeyRepository.ts @@ -0,0 +1,35 @@ +export default interface FollowKeyRepository { + getKeys(): string[]; + + pushKey(key: string): void; + + popKey(): void; + + clearKeys(): void; + + // eslint-disable-next-line semi +} + +const current: { + keys: string[]; +} = { + keys: [], +}; + +export class FollowKeyRepositoryImpl implements FollowKeyRepository { + getKeys(): string[] { + return current.keys; + } + + pushKey(key: string): void { + current.keys.push(key); + } + + popKey(): void { + current.keys.pop(); + } + + clearKeys(): void { + current.keys = []; + } +} diff --git a/src/content/repositories/FollowMasterRepository.ts b/src/content/repositories/FollowMasterRepository.ts new file mode 100644 index 0000000..a964953 --- /dev/null +++ b/src/content/repositories/FollowMasterRepository.ts @@ -0,0 +1,59 @@ +export default interface FollowMasterRepository { + setCurrentFollowMode(newTab: boolean, background: boolean): void; + + getTags(): string[]; + + getTagsByPrefix(prefix: string): string[]; + + addTag(tag: string): void; + + clearTags(): void; + + getCurrentNewTabMode(): boolean; + + getCurrentBackgroundMode(): boolean; + + // eslint-disable-next-line semi +} + +const current: { + newTab: boolean; + background: boolean; + tags: string[]; +} = { + newTab: false, + background: false, + tags: [], +}; + +export class FollowMasterRepositoryImpl implements FollowMasterRepository { + setCurrentFollowMode(newTab: boolean, background: boolean): void { + current.newTab = newTab; + current.background = background; + } + + getTags(): string[] { + return current.tags; + } + + getTagsByPrefix(prefix: string): string[] { + return current.tags.filter(t => t.startsWith(prefix)); + } + + addTag(tag: string): void { + current.tags.push(tag); + } + + clearTags(): void { + current.tags = []; + } + + getCurrentNewTabMode(): boolean { + return current.newTab; + } + + getCurrentBackgroundMode(): boolean { + return current.background; + } +} + diff --git a/src/content/repositories/FollowSlaveRepository.ts b/src/content/repositories/FollowSlaveRepository.ts new file mode 100644 index 0000000..4c2de72 --- /dev/null +++ b/src/content/repositories/FollowSlaveRepository.ts @@ -0,0 +1,31 @@ +export default interface FollowSlaveRepository { + enableFollowMode(): void; + + disableFollowMode(): void; + + isFollowMode(): boolean; + + // eslint-disable-next-line semi +} + +const current: { + enabled: boolean; +} = { + enabled: false, +}; + +export class FollowSlaveRepositoryImpl implements FollowSlaveRepository { + enableFollowMode(): void { + current.enabled = true; + } + + disableFollowMode(): void { + current.enabled = false; + } + + isFollowMode(): boolean { + return current.enabled; + } +} + + diff --git a/src/content/usecases/FollowMasterUseCase.ts b/src/content/usecases/FollowMasterUseCase.ts new file mode 100644 index 0000000..c2c6835 --- /dev/null +++ b/src/content/usecases/FollowMasterUseCase.ts @@ -0,0 +1,150 @@ +import FollowKeyRepository, { FollowKeyRepositoryImpl } + from '../repositories/FollowKeyRepository'; +import FollowMasterRepository, { FollowMasterRepositoryImpl } + from '../repositories/FollowMasterRepository'; +import FollowSlaveClient, { FollowSlaveClientImpl } + from '../client/FollowSlaveClient'; +import HintKeyProducer from '../hint-key-producer'; +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; + +export default class FollowMasterUseCase { + private followKeyRepository: FollowKeyRepository; + + private followMasterRepository: FollowMasterRepository; + + private settingRepository: SettingRepository; + + // TODO Make repository + private producer: HintKeyProducer | null; + + constructor({ + followKeyRepository = new FollowKeyRepositoryImpl(), + followMasterRepository = new FollowMasterRepositoryImpl(), + settingRepository = new SettingRepositoryImpl(), + } = {}) { + this.followKeyRepository = followKeyRepository; + this.followMasterRepository = followMasterRepository; + this.settingRepository = settingRepository; + this.producer = null; + } + + startFollow(newTab: boolean, background: boolean): void { + let hintchars = this.settingRepository.get().properties.hintchars; + this.producer = new HintKeyProducer(hintchars); + + this.followKeyRepository.clearKeys(); + this.followMasterRepository.setCurrentFollowMode(newTab, background); + + let viewWidth = window.top.innerWidth; + let viewHeight = window.top.innerHeight; + new FollowSlaveClientImpl(window.top).requestHintCount( + { width: viewWidth, height: viewHeight }, + { x: 0, y: 0 }, + ); + + let frameElements = window.document.querySelectorAll('iframe'); + for (let i = 0; i < frameElements.length; ++i) { + let ele = frameElements[i] as HTMLFrameElement | HTMLIFrameElement; + let { left: frameX, top: frameY } = ele.getBoundingClientRect(); + new FollowSlaveClientImpl(ele.contentWindow!!).requestHintCount( + { width: viewWidth, height: viewHeight }, + { x: frameX, y: frameY }, + ); + } + } + + // eslint-disable-next-line max-statements + createSlaveHints(count: number, sender: Window): void { + let produced = []; + for (let i = 0; i < count; ++i) { + let tag = this.producer!!.produce(); + produced.push(tag); + this.followMasterRepository.addTag(tag); + } + + let doc = window.document; + let viewWidth = window.innerWidth || doc.documentElement.clientWidth; + let viewHeight = window.innerHeight || doc.documentElement.clientHeight; + let pos = { x: 0, y: 0 }; + if (sender !== window) { + let frameElements = window.document.querySelectorAll('iframe'); + let ele = Array.from(frameElements).find(e => e.contentWindow === sender); + if (!ele) { + // elements of the sender is gone + return; + } + let { left: frameX, top: frameY } = ele.getBoundingClientRect(); + pos = { x: frameX, y: frameY }; + } + new FollowSlaveClientImpl(sender).createHints( + { width: viewWidth, height: viewHeight }, + pos, + produced, + ); + } + + cancelFollow(): void { + this.followMasterRepository.clearTags(); + this.broadcastToSlaves((client) => { + client.clearHints(); + }); + } + + filter(prefix: string): void { + this.broadcastToSlaves((client) => { + client.filterHints(prefix); + }); + } + + activate(tag: string): void { + this.followMasterRepository.clearTags(); + + let newTab = this.followMasterRepository.getCurrentNewTabMode(); + let background = this.followMasterRepository.getCurrentBackgroundMode(); + this.broadcastToSlaves((client) => { + client.activateIfExists(tag, newTab, background); + client.clearHints(); + }); + } + + enqueue(key: string): void { + switch (key) { + case 'Enter': + this.activate(this.getCurrentTag()); + return; + case 'Esc': + this.cancelFollow(); + return; + case 'Backspace': + case 'Delete': + this.followKeyRepository.popKey(); + this.filter(this.getCurrentTag()); + return; + } + + this.followKeyRepository.pushKey(key); + + let tag = this.getCurrentTag(); + let matched = this.followMasterRepository.getTagsByPrefix(tag); + if (matched.length === 0) { + this.cancelFollow(); + } else if (matched.length === 1) { + this.activate(tag); + } else { + this.filter(tag); + } + } + + private broadcastToSlaves(handler: (client: FollowSlaveClient) => void) { + let allFrames = [window.self].concat(Array.from(window.frames as any)); + let clients = allFrames.map(frame => new FollowSlaveClientImpl(frame)); + for (let client of clients) { + handler(client); + } + } + + private getCurrentTag(): string { + return this.followKeyRepository.getKeys().join(''); + } +} diff --git a/src/content/usecases/FollowSlaveUseCase.ts b/src/content/usecases/FollowSlaveUseCase.ts new file mode 100644 index 0000000..eb011de --- /dev/null +++ b/src/content/usecases/FollowSlaveUseCase.ts @@ -0,0 +1,91 @@ +import FollowSlaveRepository, { FollowSlaveRepositoryImpl } + from '../repositories/FollowSlaveRepository'; +import FollowPresenter, { FollowPresenterImpl } + from '../presenters/FollowPresenter'; +import TabsClient, { TabsClientImpl } from '../client/TabsClient'; +import { LinkHint, InputHint } from '../presenters/Hint'; +import FollowMasterClient, { FollowMasterClientImpl } + from '../client/FollowMasterClient'; +import Key from '../domains/Key'; + +interface Size { + width: number; + height: number; +} + +interface Point { + x: number; + y: number; +} + +export default class FollowSlaveUseCase { + private presenter: FollowPresenter; + + private tabsClient: TabsClient; + + private followMasterClient: FollowMasterClient; + + private followSlaveRepository: FollowSlaveRepository; + + constructor({ + presenter = new FollowPresenterImpl(), + tabsClient = new TabsClientImpl(), + followMasterClient = new FollowMasterClientImpl(window.top), + followSlaveRepository = new FollowSlaveRepositoryImpl(), + } = {}) { + this.presenter = presenter; + this.tabsClient = tabsClient; + this.followMasterClient = followMasterClient; + this.followSlaveRepository = followSlaveRepository; + } + + countTargets(viewSize: Size, framePosition: Point): void { + let count = this.presenter.getTargetCount(viewSize, framePosition); + this.followMasterClient.responseHintCount(count); + } + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void { + this.followSlaveRepository.enableFollowMode(); + this.presenter.createHints(viewSize, framePosition, tags); + } + + showHints(prefix: string) { + this.presenter.filterHints(prefix); + } + + sendKey(key: Key): void { + this.followMasterClient.sendKey(key); + } + + isFollowMode(): boolean { + return this.followSlaveRepository.isFollowMode(); + } + + async activate(tag: string, newTab: boolean, background: boolean) { + let hint = this.presenter.getHint(tag); + if (!hint) { + return; + } + + if (hint instanceof LinkHint) { + let url = hint.getLink(); + // ignore taget='_blank' + if (!newTab && hint.getLinkTarget() === '_blank') { + hint.click(); + return; + } + // eslint-disable-next-line no-script-url + if (!url || url === '#' || url.toLowerCase().startsWith('javascript:')) { + return; + } + await this.tabsClient.openUrl(url, newTab, background); + } else if (hint instanceof InputHint) { + hint.activate(); + } + } + + clear(): void { + this.followSlaveRepository.disableFollowMode(); + this.presenter.clearHints(); + } +} diff --git a/test/content/InputDriver.test.ts b/test/content/InputDriver.test.ts index ac5f95d..b9f2c28 100644 --- a/test/content/InputDriver.test.ts +++ b/test/content/InputDriver.test.ts @@ -1,6 +1,6 @@ import InputDriver from '../../src/content/InputDriver'; import { expect } from 'chai'; -import { Key } from '../../src/shared/utils/keys'; +import Key from '../../src/content/domains/Key'; describe('InputDriver', () => { let target: HTMLElement;