diff options
author | Shin'ya Ueoka <ueokande@i-beam.org> | 2019-05-19 15:59:05 +0900 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-05-19 15:59:05 +0900 |
commit | 3f4bc62ed515f1c5da90ee1c3e42f3d435ea6e39 (patch) | |
tree | 8af9f8e5b12d007ce9628b40f3046b73f18e29f8 | |
parent | 6ec560bca33e774ff7e363270c423c919fdcf4ce (diff) | |
parent | c4dcdff9844e2404e3bc035f4cea9fce2f7770ab (diff) |
Merge pull request #587 from ueokande/refactor-content
Refactor content scripts
131 files changed, 3825 insertions, 2350 deletions
@@ -79,6 +79,6 @@ "react/jsx-indent": ["error", 2], "react/prop-types": "off", "react/react-in-jsx-scope": "off", - "@typescript-eslint/no-unused-vars": "error" + "@typescript-eslint/no-unused-vars": ["error", { args: "none" }], } } diff --git a/src/background/infrastructures/ContentMessageClient.ts b/src/background/infrastructures/ContentMessageClient.ts index d4bc476..2215330 100644 --- a/src/background/infrastructures/ContentMessageClient.ts +++ b/src/background/infrastructures/ContentMessageClient.ts @@ -14,10 +14,10 @@ export default class ContentMessageClient { } async getAddonEnabled(tabId: number): Promise<boolean> { - let { enabled } = await browser.tabs.sendMessage(tabId, { + let enabled = await browser.tabs.sendMessage(tabId, { type: messages.ADDON_ENABLED_QUERY, - }) as { enabled: boolean }; - return enabled; + }); + return enabled as any as boolean; } toggleAddonEnabled(tabId: number): Promise<void> { diff --git a/src/console/actions/console.ts b/src/console/actions/console.ts index b1494b0..d03f52c 100644 --- a/src/console/actions/console.ts +++ b/src/console/actions/console.ts @@ -53,7 +53,7 @@ const enterCommand = async( return hideCommand(); }; -const enterFind = (text: string): actions.ConsoleAction => { +const enterFind = (text?: string): actions.ConsoleAction => { window.top.postMessage(JSON.stringify({ type: messages.CONSOLE_ENTER_FIND, text, diff --git a/src/console/components/Console.tsx b/src/console/components/Console.tsx index 3274047..68cc523 100644 --- a/src/console/components/Console.tsx +++ b/src/console/components/Console.tsx @@ -38,7 +38,8 @@ class Console extends React.Component<Props> { if (this.props.mode === 'command') { return this.props.dispatch(consoleActions.enterCommand(value)); } else if (this.props.mode === 'find') { - return this.props.dispatch(consoleActions.enterFind(value)); + return this.props.dispatch(consoleActions.enterFind( + value === '' ? undefined : value)); } } diff --git a/src/content/components/common/input.ts b/src/content/InputDriver.ts index 1fe34c9..cddc825 100644 --- a/src/content/components/common/input.ts +++ b/src/content/InputDriver.ts @@ -1,14 +1,14 @@ -import * as dom from '../../../shared/utils/dom'; -import * as keys from '../../../shared/utils/keys'; +import * as dom from '../shared/utils/dom'; +import Key, * as keys from './domains/Key'; 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)[] = []; + private onKeyListeners: ((key: Key) => boolean)[] = []; constructor(target: HTMLElement) { this.pressed = {}; @@ -19,11 +19,11 @@ export default class InputComponent { target.addEventListener('keyup', this.onKeyUp.bind(this)); } - onKey(cb: (key: keys.Key) => boolean) { + onKey(cb: (key: Key) => boolean) { 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/MessageListener.ts b/src/content/MessageListener.ts index 105d028..e545cab 100644 --- a/src/content/MessageListener.ts +++ b/src/content/MessageListener.ts @@ -1,14 +1,16 @@ import { Message, valueOf } from '../shared/messages'; -export type WebMessageSender = Window | MessagePort | ServiceWorker | null; export type WebExtMessageSender = browser.runtime.MessageSender; export default class MessageListener { onWebMessage( - listener: (msg: Message, sender: WebMessageSender) => void, + listener: (msg: Message, sender: Window) => void, ) { window.addEventListener('message', (event: MessageEvent) => { let sender = event.source; + if (!(sender instanceof Window)) { + return; + } let message = null; try { message = JSON.parse(event.data); @@ -25,7 +27,7 @@ export default class MessageListener { ) { browser.runtime.onMessage.addListener( (msg: any, sender: WebExtMessageSender) => { - listener(valueOf(msg), sender); + return listener(valueOf(msg), sender); }, ); } diff --git a/src/content/actions/addon.ts b/src/content/actions/addon.ts deleted file mode 100644 index 8dedae0..0000000 --- a/src/content/actions/addon.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as messages from '../../shared/messages'; -import * as actions from './index'; - -const enable = (): Promise<actions.AddonAction> => setEnabled(true); - -const disable = (): Promise<actions.AddonAction> => setEnabled(false); - -const setEnabled = async(enabled: boolean): Promise<actions.AddonAction> => { - await browser.runtime.sendMessage({ - type: messages.ADDON_ENABLED_RESPONSE, - enabled, - }); - return { - type: actions.ADDON_SET_ENABLED, - enabled, - }; -}; - -export { enable, disable, setEnabled }; diff --git a/src/content/actions/find.ts b/src/content/actions/find.ts deleted file mode 100644 index 53e03ae..0000000 --- a/src/content/actions/find.ts +++ /dev/null @@ -1,100 +0,0 @@ -// -// window.find(aString, aCaseSensitive, aBackwards, aWrapAround, -// aWholeWord, aSearchInFrames); -// -// NOTE: window.find is not standard API -// https://developer.mozilla.org/en-US/docs/Web/API/Window/find - -import * as messages from '../../shared/messages'; -import * as actions from './index'; -import * as consoleFrames from '../console-frames'; - -interface MyWindow extends Window { - find( - aString: string, - aCaseSensitive?: boolean, - aBackwards?: boolean, - aWrapAround?: boolean, - aWholeWord?: boolean, - aSearchInFrames?: boolean, - aShowDialog?: boolean): boolean; -} - -// eslint-disable-next-line no-var, vars-on-top, init-declarations -declare var window: MyWindow; - -const find = (str: string, backwards: boolean): boolean => { - let caseSensitive = false; - let wrapScan = true; - - - // NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work - // because of same origin policy - - // eslint-disable-next-line no-extra-parens - let found = window.find(str, caseSensitive, backwards, wrapScan); - if (found) { - return found; - } - let sel = window.getSelection(); - if (sel) { - sel.removeAllRanges(); - } - - // eslint-disable-next-line no-extra-parens - return window.find(str, caseSensitive, backwards, wrapScan); -}; - -// eslint-disable-next-line max-statements -const findNext = async( - currentKeyword: string, reset: boolean, backwards: boolean, -): Promise<actions.FindAction> => { - if (reset) { - let sel = window.getSelection(); - if (sel) { - sel.removeAllRanges(); - } - } - - let keyword = currentKeyword; - if (currentKeyword) { - browser.runtime.sendMessage({ - type: messages.FIND_SET_KEYWORD, - keyword: currentKeyword, - }); - } else { - keyword = await browser.runtime.sendMessage({ - type: messages.FIND_GET_KEYWORD, - }); - } - if (!keyword) { - await consoleFrames.postError('No previous search keywords'); - return { type: actions.NOOP }; - } - let found = find(keyword, backwards); - if (found) { - consoleFrames.postInfo('Pattern found: ' + keyword); - } else { - consoleFrames.postError('Pattern not found: ' + keyword); - } - - return { - type: actions.FIND_SET_KEYWORD, - keyword, - found, - }; -}; - -const next = ( - currentKeyword: string, reset: boolean, -): Promise<actions.FindAction> => { - return findNext(currentKeyword, reset, false); -}; - -const prev = ( - currentKeyword: string, reset: boolean, -): Promise<actions.FindAction> => { - return findNext(currentKeyword, reset, true); -}; - -export { next, prev }; diff --git a/src/content/actions/follow-controller.ts b/src/content/actions/follow-controller.ts deleted file mode 100644 index 115b3b6..0000000 --- a/src/content/actions/follow-controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import * as actions from './index'; - -const enable = ( - newTab: boolean, background: boolean, -): actions.FollowAction => { - return { - type: actions.FOLLOW_CONTROLLER_ENABLE, - newTab, - background, - }; -}; - -const disable = (): actions.FollowAction => { - return { - type: actions.FOLLOW_CONTROLLER_DISABLE, - }; -}; - -const keyPress = (key: string): actions.FollowAction => { - return { - type: actions.FOLLOW_CONTROLLER_KEY_PRESS, - key: key - }; -}; - -const backspace = (): actions.FollowAction => { - return { - type: actions.FOLLOW_CONTROLLER_BACKSPACE, - }; -}; - -export { enable, disable, keyPress, backspace }; diff --git a/src/content/actions/index.ts b/src/content/actions/index.ts deleted file mode 100644 index 8aa9c23..0000000 --- a/src/content/actions/index.ts +++ /dev/null @@ -1,122 +0,0 @@ -import Redux from 'redux'; -import Settings from '../../shared/Settings'; -import * as keyUtils from '../../shared/utils/keys'; - -// Enable/disable -export const ADDON_SET_ENABLED = 'addon.set.enabled'; - -// Find -export const FIND_SET_KEYWORD = 'find.set.keyword'; - -// Settings -export const SETTING_SET = 'setting.set'; - -// User input -export const INPUT_KEY_PRESS = 'input.key.press'; -export const INPUT_CLEAR_KEYS = 'input.clear.keys'; - -// Completion -export const COMPLETION_SET_ITEMS = 'completion.set.items'; -export const COMPLETION_SELECT_NEXT = 'completions.select.next'; -export const COMPLETION_SELECT_PREV = 'completions.select.prev'; - -// Follow -export const FOLLOW_CONTROLLER_ENABLE = 'follow.controller.enable'; -export const FOLLOW_CONTROLLER_DISABLE = 'follow.controller.disable'; -export const FOLLOW_CONTROLLER_KEY_PRESS = 'follow.controller.key.press'; -export const FOLLOW_CONTROLLER_BACKSPACE = 'follow.controller.backspace'; - -// Mark -export const MARK_START_SET = 'mark.start.set'; -export const MARK_START_JUMP = 'mark.start.jump'; -export const MARK_CANCEL = 'mark.cancel'; -export const MARK_SET_LOCAL = 'mark.set.local'; - -export const NOOP = 'noop'; - -export interface AddonSetEnabledAction extends Redux.Action { - type: typeof ADDON_SET_ENABLED; - enabled: boolean; -} - -export interface FindSetKeywordAction extends Redux.Action { - type: typeof FIND_SET_KEYWORD; - keyword: string; - found: boolean; -} - -export interface SettingSetAction extends Redux.Action { - type: typeof SETTING_SET; - settings: Settings, -} - -export interface InputKeyPressAction extends Redux.Action { - type: typeof INPUT_KEY_PRESS; - key: keyUtils.Key; -} - -export interface InputClearKeysAction extends Redux.Action { - type: typeof INPUT_CLEAR_KEYS; -} - -export interface FollowControllerEnableAction extends Redux.Action { - type: typeof FOLLOW_CONTROLLER_ENABLE; - newTab: boolean; - background: boolean; -} - -export interface FollowControllerDisableAction extends Redux.Action { - type: typeof FOLLOW_CONTROLLER_DISABLE; -} - -export interface FollowControllerKeyPressAction extends Redux.Action { - type: typeof FOLLOW_CONTROLLER_KEY_PRESS; - key: string; -} - -export interface FollowControllerBackspaceAction extends Redux.Action { - type: typeof FOLLOW_CONTROLLER_BACKSPACE; -} - -export interface MarkStartSetAction extends Redux.Action { - type: typeof MARK_START_SET; -} - -export interface MarkStartJumpAction extends Redux.Action { - type: typeof MARK_START_JUMP; -} - -export interface MarkCancelAction extends Redux.Action { - type: typeof MARK_CANCEL; -} - -export interface MarkSetLocalAction extends Redux.Action { - type: typeof MARK_SET_LOCAL; - key: string; - x: number; - y: number; -} - -export interface NoopAction extends Redux.Action { - type: typeof NOOP; -} - -export type AddonAction = AddonSetEnabledAction; -export type FindAction = FindSetKeywordAction | NoopAction; -export type SettingAction = SettingSetAction; -export type InputAction = InputKeyPressAction | InputClearKeysAction; -export type FollowAction = - FollowControllerEnableAction | FollowControllerDisableAction | - FollowControllerKeyPressAction | FollowControllerBackspaceAction; -export type MarkAction = - MarkStartSetAction | MarkStartJumpAction | - MarkCancelAction | MarkSetLocalAction | NoopAction; - -export type Action = - AddonAction | - FindAction | - SettingAction | - InputAction | - FollowAction | - MarkAction | - NoopAction; diff --git a/src/content/actions/input.ts b/src/content/actions/input.ts deleted file mode 100644 index 1df6452..0000000 --- a/src/content/actions/input.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as actions from './index'; -import * as keyUtils from '../../shared/utils/keys'; - -const keyPress = (key: keyUtils.Key): actions.InputAction => { - return { - type: actions.INPUT_KEY_PRESS, - key, - }; -}; - -const clearKeys = (): actions.InputAction => { - return { - type: actions.INPUT_CLEAR_KEYS - }; -}; - -export { keyPress, clearKeys }; diff --git a/src/content/actions/mark.ts b/src/content/actions/mark.ts deleted file mode 100644 index 5eb9554..0000000 --- a/src/content/actions/mark.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as actions from './index'; -import * as messages from '../../shared/messages'; - -const startSet = (): actions.MarkAction => { - return { type: actions.MARK_START_SET }; -}; - -const startJump = (): actions.MarkAction => { - return { type: actions.MARK_START_JUMP }; -}; - -const cancel = (): actions.MarkAction => { - return { type: actions.MARK_CANCEL }; -}; - -const setLocal = (key: string, x: number, y: number): actions.MarkAction => { - return { - type: actions.MARK_SET_LOCAL, - key, - x, - y, - }; -}; - -const setGlobal = (key: string, x: number, y: number): actions.MarkAction => { - browser.runtime.sendMessage({ - type: messages.MARK_SET_GLOBAL, - key, - x, - y, - }); - return { type: actions.NOOP }; -}; - -const jumpGlobal = (key: string): actions.MarkAction => { - browser.runtime.sendMessage({ - type: messages.MARK_JUMP_GLOBAL, - key, - }); - return { type: actions.NOOP }; -}; - -export { - startSet, startJump, cancel, setLocal, - setGlobal, jumpGlobal, -}; diff --git a/src/content/actions/operation.ts b/src/content/actions/operation.ts deleted file mode 100644 index 41e080b..0000000 --- a/src/content/actions/operation.ts +++ /dev/null @@ -1,107 +0,0 @@ -import * as operations from '../../shared/operations'; -import * as actions from './index'; -import * as messages from '../../shared/messages'; -import * as scrolls from '../scrolls'; -import * as navigates from '../navigates'; -import * as focuses from '../focuses'; -import * as urls from '../urls'; -import * as consoleFrames from '../console-frames'; -import * as addonActions from './addon'; -import * as markActions from './mark'; - -// eslint-disable-next-line complexity, max-lines-per-function -const exec = ( - operation: operations.Operation, - settings: any, - addonEnabled: boolean, -): Promise<actions.Action> | actions.Action => { - let smoothscroll = settings.properties.smoothscroll; - switch (operation.type) { - case operations.ADDON_ENABLE: - return addonActions.enable(); - case operations.ADDON_DISABLE: - return addonActions.disable(); - case operations.ADDON_TOGGLE_ENABLED: - return addonActions.setEnabled(!addonEnabled); - case operations.FIND_NEXT: - window.top.postMessage(JSON.stringify({ - type: messages.FIND_NEXT, - }), '*'); - break; - case operations.FIND_PREV: - window.top.postMessage(JSON.stringify({ - type: messages.FIND_PREV, - }), '*'); - break; - case operations.SCROLL_VERTICALLY: - scrolls.scrollVertically(operation.count, smoothscroll); - break; - case operations.SCROLL_HORIZONALLY: - scrolls.scrollHorizonally(operation.count, smoothscroll); - break; - case operations.SCROLL_PAGES: - scrolls.scrollPages(operation.count, smoothscroll); - break; - case operations.SCROLL_TOP: - scrolls.scrollToTop(smoothscroll); - break; - case operations.SCROLL_BOTTOM: - scrolls.scrollToBottom(smoothscroll); - break; - case operations.SCROLL_HOME: - scrolls.scrollToHome(smoothscroll); - break; - case operations.SCROLL_END: - scrolls.scrollToEnd(smoothscroll); - 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: - navigates.historyPrev(window); - break; - case operations.NAVIGATE_HISTORY_NEXT: - navigates.historyNext(window); - break; - case operations.NAVIGATE_LINK_PREV: - navigates.linkPrev(window); - break; - case operations.NAVIGATE_LINK_NEXT: - navigates.linkNext(window); - break; - case operations.NAVIGATE_PARENT: - navigates.parent(window); - break; - case operations.NAVIGATE_ROOT: - navigates.root(window); - break; - case operations.FOCUS_INPUT: - focuses.focusInput(); - break; - case operations.URLS_YANK: - urls.yank(window); - consoleFrames.postInfo('Yanked ' + window.location.href); - break; - case operations.URLS_PASTE: - urls.paste( - window, operation.newTab ? operation.newTab : false, settings.search - ); - break; - default: - browser.runtime.sendMessage({ - type: messages.BACKGROUND_OPERATION, - operation, - }); - } - return { type: actions.NOOP }; -}; - -export { exec }; diff --git a/src/content/actions/setting.ts b/src/content/actions/setting.ts deleted file mode 100644 index 92f8559..0000000 --- a/src/content/actions/setting.ts +++ /dev/null @@ -1,28 +0,0 @@ -import * as actions from './index'; -import * as operations from '../../shared/operations'; -import * as messages from '../../shared/messages'; -import Settings, { Keymaps } from '../../shared/Settings'; - -const reservedKeymaps: Keymaps = { - '<Esc>': { type: operations.CANCEL }, - '<C-[>': { type: operations.CANCEL }, -}; - -const set = (settings: Settings): actions.SettingAction => { - return { - type: actions.SETTING_SET, - settings: { - ...settings, - keymaps: { ...settings.keymaps, ...reservedKeymaps }, - } - }; -}; - -const load = async(): Promise<actions.SettingAction> => { - let settings = await browser.runtime.sendMessage({ - type: messages.SETTINGS_QUERY, - }); - return set(settings); -}; - -export { set, load }; diff --git a/src/content/client/AddonIndicatorClient.ts b/src/content/client/AddonIndicatorClient.ts new file mode 100644 index 0000000..afb9fa4 --- /dev/null +++ b/src/content/client/AddonIndicatorClient.ts @@ -0,0 +1,16 @@ +import * as messages from '../../shared/messages'; + +export default interface AddonIndicatorClient { + setEnabled(enabled: boolean): Promise<void>; + + // eslint-disable-next-line semi +} + +export class AddonIndicatorClientImpl implements AddonIndicatorClient { + setEnabled(enabled: boolean): Promise<void> { + return browser.runtime.sendMessage({ + type: messages.ADDON_ENABLED_RESPONSE, + enabled, + }); + } +} 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/ConsoleClient.ts b/src/content/client/ConsoleClient.ts new file mode 100644 index 0000000..e7046e5 --- /dev/null +++ b/src/content/client/ConsoleClient.ts @@ -0,0 +1,30 @@ +import * as messages from '../../shared/messages'; + +export default interface ConsoleClient { + info(text: string): Promise<void>; + error(text: string): Promise<void>; + + // eslint-disable-next-line semi +} + +export class ConsoleClientImpl implements ConsoleClient { + async info(text: string): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.CONSOLE_FRAME_MESSAGE, + message: { + type: messages.CONSOLE_SHOW_INFO, + text, + }, + }); + } + + async error(text: string): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.CONSOLE_FRAME_MESSAGE, + message: { + type: messages.CONSOLE_SHOW_ERROR, + text, + }, + }); + } +} diff --git a/src/content/client/FindClient.ts b/src/content/client/FindClient.ts new file mode 100644 index 0000000..22cd3cb --- /dev/null +++ b/src/content/client/FindClient.ts @@ -0,0 +1,25 @@ +import * as messages from '../../shared/messages'; + +export default interface FindClient { + getGlobalLastKeyword(): Promise<string | null>; + + setGlobalLastKeyword(keyword: string): Promise<void>; + + // eslint-disable-next-line semi +} + +export class FindClientImpl implements FindClient { + async getGlobalLastKeyword(): Promise<string | null> { + let keyword = await browser.runtime.sendMessage({ + type: messages.FIND_GET_KEYWORD, + }); + return keyword as string; + } + + async setGlobalLastKeyword(keyword: string): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.FIND_SET_KEYWORD, + keyword: keyword, + }); + } +} 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/client/FollowMasterClient.ts b/src/content/client/FollowMasterClient.ts new file mode 100644 index 0000000..c841902 --- /dev/null +++ b/src/content/client/FollowMasterClient.ts @@ -0,0 +1,47 @@ +import * as messages from '../../shared/messages'; +import Key from '../domains/Key'; + +export default interface FollowMasterClient { + startFollow(newTab: boolean, background: boolean): void; + + responseHintCount(count: number): void; + + sendKey(key: Key): void; + + // eslint-disable-next-line semi +} + +export class FollowMasterClientImpl implements FollowMasterClient { + private window: Window; + + constructor(window: Window) { + this.window = window; + } + + startFollow(newTab: boolean, background: boolean): void { + this.postMessage({ + type: messages.FOLLOW_START, + newTab, + background, + }); + } + + responseHintCount(count: number): void { + this.postMessage({ + type: messages.FOLLOW_RESPONSE_COUNT_TARGETS, + count, + }); + } + + sendKey(key: Key): void { + this.postMessage({ + type: messages.FOLLOW_KEY_PRESS, + key: key.key, + ctrlKey: key.ctrlKey || false, + }); + } + + private postMessage(msg: messages.Message): void { + this.window.postMessage(JSON.stringify(msg), '*'); + } +} diff --git a/src/content/client/FollowSlaveClient.ts b/src/content/client/FollowSlaveClient.ts new file mode 100644 index 0000000..0905cd9 --- /dev/null +++ b/src/content/client/FollowSlaveClient.ts @@ -0,0 +1,76 @@ +import * as messages from '../../shared/messages'; + +interface Size { + width: number; + height: number; +} + +interface Point { + x: number; + y: number; +} + +export default interface FollowSlaveClient { + filterHints(prefix: string): void; + + requestHintCount(viewSize: Size, framePosition: Point): void; + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void; + + clearHints(): void; + + activateIfExists(tag: string, newTab: boolean, background: boolean): void; + + // eslint-disable-next-line semi +} + +export class FollowSlaveClientImpl implements FollowSlaveClient { + private target: Window; + + constructor(target: Window) { + this.target = target; + } + + filterHints(prefix: string): void { + this.postMessage({ + type: messages.FOLLOW_SHOW_HINTS, + prefix, + }); + } + + requestHintCount(viewSize: Size, framePosition: Point): void { + this.postMessage({ + type: messages.FOLLOW_REQUEST_COUNT_TARGETS, + viewSize, + framePosition, + }); + } + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void { + this.postMessage({ + type: messages.FOLLOW_CREATE_HINTS, + viewSize, + framePosition, + tags, + }); + } + + clearHints(): void { + this.postMessage({ + type: messages.FOLLOW_REMOVE_HINTS, + }); + } + + activateIfExists(tag: string, newTab: boolean, background: boolean): void { + this.postMessage({ + type: messages.FOLLOW_ACTIVATE, + tag, + newTab, + background, + }); + } + + private postMessage(msg: messages.Message): void { + this.target.postMessage(JSON.stringify(msg), '*'); + } +} diff --git a/src/content/client/MarkClient.ts b/src/content/client/MarkClient.ts new file mode 100644 index 0000000..b7cf535 --- /dev/null +++ b/src/content/client/MarkClient.ts @@ -0,0 +1,28 @@ +import Mark from '../domains/Mark'; +import * as messages from '../../shared/messages'; + +export default interface MarkClient { + setGloablMark(key: string, mark: Mark): Promise<void>; + + jumpGlobalMark(key: string): Promise<void>; + + // eslint-disable-next-line semi +} + +export class MarkClientImpl implements MarkClient { + async setGloablMark(key: string, mark: Mark): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.MARK_SET_GLOBAL, + key, + x: mark.x, + y: mark.y, + }); + } + + async jumpGlobalMark(key: string): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.MARK_JUMP_GLOBAL, + key, + }); + } +} diff --git a/src/content/client/SettingClient.ts b/src/content/client/SettingClient.ts new file mode 100644 index 0000000..c67f544 --- /dev/null +++ b/src/content/client/SettingClient.ts @@ -0,0 +1,17 @@ +import Settings from '../../shared/Settings'; +import * as messages from '../../shared/messages'; + +export default interface SettingClient { + load(): Promise<Settings>; + + // eslint-disable-next-line semi +} + +export class SettingClientImpl { + async load(): Promise<Settings> { + let settings = await browser.runtime.sendMessage({ + type: messages.SETTINGS_QUERY, + }); + return settings as Settings; + } +} diff --git a/src/content/client/TabsClient.ts b/src/content/client/TabsClient.ts new file mode 100644 index 0000000..e1af078 --- /dev/null +++ b/src/content/client/TabsClient.ts @@ -0,0 +1,22 @@ +import * as messages from '../../shared/messages'; + +export default interface TabsClient { + openUrl(url: string, newTab: boolean, background?: boolean): Promise<void>; + + // eslint-disable-next-line semi +} + +export class TabsClientImpl implements TabsClient { + async openUrl( + url: string, + newTab: boolean, + background?: boolean, + ): Promise<void> { + await browser.runtime.sendMessage({ + type: messages.OPEN_URL, + url, + newTab, + background, + }); + } +} diff --git a/src/content/components/common/follow.ts b/src/content/components/common/follow.ts deleted file mode 100644 index 67f2dd9..0000000 --- a/src/content/components/common/follow.ts +++ /dev/null @@ -1,231 +0,0 @@ -import MessageListener from '../../MessageListener'; -import Hint from './hint'; -import * as dom from '../../../shared/utils/dom'; -import * as messages from '../../../shared/messages'; -import * as keyUtils from '../../../shared/utils/keys'; - -const TARGET_SELECTOR = [ - 'a', 'button', 'input', 'textarea', 'area', - '[contenteditable=true]', '[contenteditable=""]', '[tabindex]', - '[role="button"]', 'summary' -].join(','); - -interface Size { - width: number; - height: number; -} - -interface Point { - x: number; - y: number; -} - -const inViewport = ( - win: Window, - element: Element, - viewSize: Size, - framePosition: Point, -): boolean => { - let { - top, left, bottom, right - } = dom.viewportRect(element); - let doc = win.document; - let frameWidth = doc.documentElement.clientWidth; - let frameHeight = doc.documentElement.clientHeight; - - if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) { - // out of frame - return false; - } - if (right + framePosition.x < 0 || bottom + framePosition.y < 0 || - left + framePosition.x > viewSize.width || - top + framePosition.y > viewSize.height) { - // out of viewport - return false; - } - return true; -}; - -const isAriaHiddenOrAriaDisabled = (win: Window, element: Element): boolean => { - if (!element || win.document.documentElement === element) { - return false; - } - for (let attr of ['aria-hidden', 'aria-disabled']) { - let value = element.getAttribute(attr); - if (value !== null) { - let hidden = value.toLowerCase(); - if (hidden === '' || hidden === 'true') { - return true; - } - } - } - return isAriaHiddenOrAriaDisabled(win, element.parentElement as Element); -}; - -export default class Follow { - private win: Window; - - private newTab: boolean; - - private background: boolean; - - private hints: {[key: string]: Hint }; - - private targets: HTMLElement[] = []; - - constructor(win: Window) { - this.win = win; - this.newTab = false; - this.background = false; - this.hints = {}; - this.targets = []; - - new MessageListener().onWebMessage(this.onMessage.bind(this)); - } - - key(key: keyUtils.Key): boolean { - if (Object.keys(this.hints).length === 0) { - return false; - } - this.win.parent.postMessage(JSON.stringify({ - type: messages.FOLLOW_KEY_PRESS, - key: key.key, - ctrlKey: key.ctrlKey, - }), '*'); - return true; - } - - openLink(element: HTMLAreaElement|HTMLAnchorElement) { - // Browser prevent new tab by link with target='_blank' - if (!this.newTab && element.getAttribute('target') !== '_blank') { - element.click(); - return; - } - - let href = element.getAttribute('href'); - - // eslint-disable-next-line no-script-url - if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) { - return; - } - return browser.runtime.sendMessage({ - type: messages.OPEN_URL, - url: element.href, - newTab: true, - background: this.background, - }); - } - - countHints(sender: any, viewSize: Size, framePosition: Point) { - this.targets = Follow.getTargetElements(this.win, viewSize, framePosition); - sender.postMessage(JSON.stringify({ - type: messages.FOLLOW_RESPONSE_COUNT_TARGETS, - count: this.targets.length, - }), '*'); - } - - createHints(keysArray: string[], newTab: boolean, background: boolean) { - if (keysArray.length !== this.targets.length) { - throw new Error('illegal hint count'); - } - - this.newTab = newTab; - this.background = background; - this.hints = {}; - for (let i = 0; i < keysArray.length; ++i) { - let keys = keysArray[i]; - let hint = new Hint(this.targets[i], keys); - this.hints[keys] = hint; - } - } - - showHints(keys: string) { - Object.keys(this.hints).filter(key => key.startsWith(keys)) - .forEach(key => this.hints[key].show()); - Object.keys(this.hints).filter(key => !key.startsWith(keys)) - .forEach(key => this.hints[key].hide()); - } - - removeHints() { - Object.keys(this.hints).forEach((key) => { - this.hints[key].remove(); - }); - this.hints = {}; - this.targets = []; - } - - activateHints(keys: string) { - let hint = this.hints[keys]; - if (!hint) { - return; - } - let element = hint.getTarget(); - switch (element.tagName.toLowerCase()) { - case 'a': - return this.openLink(element as HTMLAnchorElement); - case 'area': - return this.openLink(element as HTMLAreaElement); - case 'input': - switch ((element as HTMLInputElement).type) { - case 'file': - case 'checkbox': - case 'radio': - case 'submit': - case 'reset': - case 'button': - case 'image': - case 'color': - return element.click(); - default: - return element.focus(); - } - case 'textarea': - return element.focus(); - case 'button': - case 'summary': - return element.click(); - default: - if (dom.isContentEditable(element)) { - return element.focus(); - } else if (element.hasAttribute('tabindex')) { - return element.click(); - } - } - } - - onMessage(message: messages.Message, sender: any) { - switch (message.type) { - case messages.FOLLOW_REQUEST_COUNT_TARGETS: - return this.countHints(sender, message.viewSize, message.framePosition); - case messages.FOLLOW_CREATE_HINTS: - return this.createHints( - message.keysArray, message.newTab, message.background); - case messages.FOLLOW_SHOW_HINTS: - return this.showHints(message.keys); - case messages.FOLLOW_ACTIVATE: - return this.activateHints(message.keys); - case messages.FOLLOW_REMOVE_HINTS: - return this.removeHints(); - } - } - - static getTargetElements( - win: Window, - viewSize: - Size, framePosition: Point, - ): HTMLElement[] { - let all = win.document.querySelectorAll(TARGET_SELECTOR); - let filtered = Array.prototype.filter.call(all, (element: HTMLElement) => { - let style = win.getComputedStyle(element); - - // AREA's 'display' in Browser style is 'none' - return (element.tagName === 'AREA' || style.display !== 'none') && - style.visibility !== 'hidden' && - (element as HTMLInputElement).type !== 'hidden' && - element.offsetHeight > 0 && - !isAriaHiddenOrAriaDisabled(win, element) && - inViewport(win, element, viewSize, framePosition); - }); - return filtered; - } -} diff --git a/src/content/components/common/hint.ts b/src/content/components/common/hint.ts deleted file mode 100644 index 2fcbb0f..0000000 --- a/src/content/components/common/hint.ts +++ /dev/null @@ -1,62 +0,0 @@ -import * as dom from '../../../shared/utils/dom'; - -interface Point { - x: number; - y: number; -} - -const hintPosition = (element: Element): Point => { - let { left, top, right, bottom } = dom.viewportRect(element); - - if (element.tagName !== 'AREA') { - return { x: left, y: top }; - } - - return { - x: (left + right) / 2, - y: (top + bottom) / 2, - }; -}; - -export default class Hint { - private target: HTMLElement; - - private element: HTMLElement; - - constructor(target: HTMLElement, tag: string) { - let doc = target.ownerDocument; - if (doc === null) { - throw new TypeError('ownerDocument is null'); - } - - let { x, y } = hintPosition(target); - let { scrollX, scrollY } = window; - - this.target = target; - - this.element = doc.createElement('span'); - this.element.className = 'vimvixen-hint'; - this.element.textContent = tag; - this.element.style.left = x + scrollX + 'px'; - this.element.style.top = y + scrollY + 'px'; - - this.show(); - doc.body.append(this.element); - } - - show(): void { - this.element.style.display = 'inline'; - } - - hide(): void { - this.element.style.display = 'none'; - } - - remove(): void { - this.element.remove(); - } - - getTarget(): HTMLElement { - return this.target; - } -} diff --git a/src/content/components/common/index.ts b/src/content/components/common/index.ts deleted file mode 100644 index 5b097b6..0000000 --- a/src/content/components/common/index.ts +++ /dev/null @@ -1,61 +0,0 @@ -import InputComponent from './input'; -import FollowComponent from './follow'; -import MarkComponent from './mark'; -import KeymapperComponent from './keymapper'; -import * as settingActions from '../../actions/setting'; -import * as messages from '../../../shared/messages'; -import MessageListener from '../../MessageListener'; -import * as addonActions from '../../actions/addon'; -import * as blacklists from '../../../shared/blacklists'; -import * as keys from '../../../shared/utils/keys'; -import * as actions from '../../actions'; - -export default class Common { - private win: Window; - - private store: any; - - constructor(win: Window, store: any) { - const input = new InputComponent(win.document.body); - const follow = new FollowComponent(win); - const mark = new MarkComponent(store); - const keymapper = new KeymapperComponent(store); - - input.onKey((key: keys.Key) => follow.key(key)); - input.onKey((key: keys.Key) => mark.key(key)); - input.onKey((key: keys.Key) => keymapper.key(key)); - - this.win = win; - this.store = store; - - this.reloadSettings(); - - new MessageListener().onBackgroundMessage(this.onMessage.bind(this)); - } - - onMessage(message: messages.Message) { - let { enabled } = this.store.getState().addon; - switch (message.type) { - case messages.SETTINGS_CHANGED: - return this.reloadSettings(); - case messages.ADDON_TOGGLE_ENABLED: - this.store.dispatch(addonActions.setEnabled(!enabled)); - } - } - - reloadSettings() { - try { - this.store.dispatch(settingActions.load()) - .then((action: actions.SettingAction) => { - let enabled = !blacklists.includes( - action.settings.blacklist, this.win.location.href - ); - this.store.dispatch(addonActions.setEnabled(enabled)); - }); - } catch (e) { - // Sometime sendMessage fails when background script is not ready. - console.warn(e); - setTimeout(() => this.reloadSettings(), 500); - } - } -} diff --git a/src/content/components/common/keymapper.ts b/src/content/components/common/keymapper.ts deleted file mode 100644 index c94bae0..0000000 --- a/src/content/components/common/keymapper.ts +++ /dev/null @@ -1,68 +0,0 @@ -import * as inputActions from '../../actions/input'; -import * as operationActions from '../../actions/operation'; -import * as operations from '../../../shared/operations'; -import * as keyUtils from '../../../shared/utils/keys'; - -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 KeymapperComponent { - private store: any; - - constructor(store: any) { - this.store = store; - } - - // eslint-disable-next-line max-statements - key(key: keyUtils.Key): boolean { - this.store.dispatch(inputActions.keyPress(key)); - - let state = this.store.getState(); - let input = state.input; - let keymaps = new Map<keyUtils.Key[], operations.Operation>( - state.setting.keymaps.map( - (e: {key: keyUtils.Key[], op: operations.Operation}) => [e.key, e.op], - ) - ); - - let matched = Array.from(keymaps.keys()).filter( - (mapping: keyUtils.Key[]) => { - return mapStartsWith(mapping, input.keys); - }); - if (!state.addon.enabled) { - // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if - // the addon disabled - matched = matched.filter((keys) => { - let type = (keymaps.get(keys) as operations.Operation).type; - return type === operations.ADDON_ENABLE || - type === operations.ADDON_TOGGLE_ENABLED; - }); - } - if (matched.length === 0) { - this.store.dispatch(inputActions.clearKeys()); - return false; - } else if (matched.length > 1 || - matched.length === 1 && input.keys.length < matched[0].length) { - return true; - } - let operation = keymaps.get(matched[0]) as operations.Operation; - let act = operationActions.exec( - operation, state.setting, state.addon.enabled - ); - this.store.dispatch(act); - this.store.dispatch(inputActions.clearKeys()); - return true; - } -} diff --git a/src/content/components/common/mark.ts b/src/content/components/common/mark.ts deleted file mode 100644 index 1237385..0000000 --- a/src/content/components/common/mark.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as markActions from '../../actions/mark'; -import * as scrolls from '../..//scrolls'; -import * as consoleFrames from '../..//console-frames'; -import * as keyUtils from '../../../shared/utils/keys'; -import Mark from '../../Mark'; - -const cancelKey = (key: keyUtils.Key): boolean => { - return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey); -}; - -const globalKey = (key: string): boolean => { - return (/^[A-Z0-9]$/).test(key); -}; - -export default class MarkComponent { - private store: any; - - constructor(store: any) { - this.store = store; - } - - // eslint-disable-next-line max-statements - key(key: keyUtils.Key) { - let { mark: markState, setting } = this.store.getState(); - let smoothscroll = setting.properties.smoothscroll; - - if (!markState.setMode && !markState.jumpMode) { - return false; - } - - if (cancelKey(key)) { - this.store.dispatch(markActions.cancel()); - return true; - } - - if (key.ctrlKey || key.metaKey || key.altKey) { - consoleFrames.postError('Unknown mark'); - } else if (globalKey(key.key) && markState.setMode) { - this.doSetGlobal(key); - } else if (globalKey(key.key) && markState.jumpMode) { - this.doJumpGlobal(key); - } else if (markState.setMode) { - this.doSet(key); - } else if (markState.jumpMode) { - this.doJump(markState.marks, key, smoothscroll); - } - - this.store.dispatch(markActions.cancel()); - return true; - } - - doSet(key: keyUtils.Key) { - let { x, y } = scrolls.getScroll(); - this.store.dispatch(markActions.setLocal(key.key, x, y)); - } - - doJump( - marks: { [key: string]: Mark }, - key: keyUtils.Key, - smoothscroll: boolean, - ) { - if (!marks[key.key]) { - consoleFrames.postError('Mark is not set'); - return; - } - - let { x, y } = marks[key.key]; - scrolls.scrollTo(x, y, smoothscroll); - } - - doSetGlobal(key: keyUtils.Key) { - let { x, y } = scrolls.getScroll(); - this.store.dispatch(markActions.setGlobal(key.key, x, y)); - } - - doJumpGlobal(key: keyUtils.Key) { - this.store.dispatch(markActions.jumpGlobal(key.key)); - } -} diff --git a/src/content/components/frame-content.ts b/src/content/components/frame-content.ts deleted file mode 100644 index ca999ba..0000000 --- a/src/content/components/frame-content.ts +++ /dev/null @@ -1,3 +0,0 @@ -import CommonComponent from './common'; - -export default CommonComponent; diff --git a/src/content/components/top-content/find.ts b/src/content/components/top-content/find.ts deleted file mode 100644 index 74b95bc..0000000 --- a/src/content/components/top-content/find.ts +++ /dev/null @@ -1,46 +0,0 @@ -import * as findActions from '../../actions/find'; -import * as messages from '../../../shared/messages'; -import MessageListener from '../../MessageListener'; - -export default class FindComponent { - private store: any; - - constructor(store: any) { - this.store = store; - - new MessageListener().onWebMessage(this.onMessage.bind(this)); - } - - onMessage(message: messages.Message) { - switch (message.type) { - case messages.CONSOLE_ENTER_FIND: - return this.start(message.text); - case messages.FIND_NEXT: - return this.next(); - case messages.FIND_PREV: - return this.prev(); - } - } - - start(text: string) { - let state = this.store.getState().find; - - if (text.length === 0) { - return this.store.dispatch( - findActions.next(state.keyword as string, true)); - } - return this.store.dispatch(findActions.next(text, true)); - } - - next() { - let state = this.store.getState().find; - return this.store.dispatch( - findActions.next(state.keyword as string, false)); - } - - prev() { - let state = this.store.getState().find; - return this.store.dispatch( - findActions.prev(state.keyword as string, false)); - } -} diff --git a/src/content/components/top-content/follow-controller.ts b/src/content/components/top-content/follow-controller.ts deleted file mode 100644 index d49b22a..0000000 --- a/src/content/components/top-content/follow-controller.ts +++ /dev/null @@ -1,166 +0,0 @@ -import * as followControllerActions from '../../actions/follow-controller'; -import * as messages from '../../../shared/messages'; -import MessageListener, { WebMessageSender } from '../../MessageListener'; -import HintKeyProducer from '../../hint-key-producer'; - -const broadcastMessage = (win: Window, message: messages.Message): void => { - let json = JSON.stringify(message); - let frames = [win.self].concat(Array.from(win.frames as any)); - frames.forEach(frame => frame.postMessage(json, '*')); -}; - -export default class FollowController { - private win: Window; - - private store: any; - - private state: { - enabled?: boolean; - newTab?: boolean; - background?: boolean; - keys?: string, - }; - - private keys: string[]; - - private producer: HintKeyProducer | null; - - constructor(win: Window, store: any) { - this.win = win; - this.store = store; - this.state = {}; - this.keys = []; - this.producer = null; - - new MessageListener().onWebMessage(this.onMessage.bind(this)); - - store.subscribe(() => { - this.update(); - }); - } - - onMessage(message: messages.Message, sender: WebMessageSender) { - switch (message.type) { - case messages.FOLLOW_START: - return this.store.dispatch( - followControllerActions.enable(message.newTab, message.background)); - case messages.FOLLOW_RESPONSE_COUNT_TARGETS: - return this.create(message.count, sender); - case messages.FOLLOW_KEY_PRESS: - return this.keyPress(message.key, message.ctrlKey); - } - } - - update(): void { - let prevState = this.state; - this.state = this.store.getState().followController; - - if (!prevState.enabled && this.state.enabled) { - this.count(); - } else if (prevState.enabled && !this.state.enabled) { - this.remove(); - } else if (prevState.keys !== this.state.keys) { - this.updateHints(); - } - } - - updateHints(): void { - let shown = this.keys.filter((key) => { - return key.startsWith(this.state.keys as string); - }); - if (shown.length === 1) { - this.activate(); - this.store.dispatch(followControllerActions.disable()); - } - - broadcastMessage(this.win, { - type: messages.FOLLOW_SHOW_HINTS, - keys: this.state.keys as string, - }); - } - - activate(): void { - broadcastMessage(this.win, { - type: messages.FOLLOW_ACTIVATE, - keys: this.state.keys as string, - }); - } - - keyPress(key: string, ctrlKey: boolean): boolean { - if (key === '[' && ctrlKey) { - this.store.dispatch(followControllerActions.disable()); - return true; - } - switch (key) { - case 'Enter': - this.activate(); - this.store.dispatch(followControllerActions.disable()); - break; - case 'Esc': - this.store.dispatch(followControllerActions.disable()); - break; - case 'Backspace': - case 'Delete': - this.store.dispatch(followControllerActions.backspace()); - break; - default: - if (this.hintchars().includes(key)) { - this.store.dispatch(followControllerActions.keyPress(key)); - } - break; - } - return true; - } - - count() { - this.producer = new HintKeyProducer(this.hintchars()); - let doc = this.win.document; - let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; - let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; - let frameElements = this.win.document.querySelectorAll('frame,iframe'); - - this.win.postMessage(JSON.stringify({ - type: messages.FOLLOW_REQUEST_COUNT_TARGETS, - viewSize: { width: viewWidth, height: viewHeight }, - framePosition: { x: 0, y: 0 }, - }), '*'); - frameElements.forEach((ele) => { - let { left: frameX, top: frameY } = ele.getBoundingClientRect(); - let message = JSON.stringify({ - type: messages.FOLLOW_REQUEST_COUNT_TARGETS, - viewSize: { width: viewWidth, height: viewHeight }, - framePosition: { x: frameX, y: frameY }, - }); - if (ele instanceof HTMLFrameElement && ele.contentWindow || - ele instanceof HTMLIFrameElement && ele.contentWindow) { - ele.contentWindow.postMessage(message, '*'); - } - }); - } - - create(count: number, sender: WebMessageSender) { - let produced = []; - for (let i = 0; i < count; ++i) { - produced.push((this.producer as HintKeyProducer).produce()); - } - this.keys = this.keys.concat(produced); - - (sender as Window).postMessage(JSON.stringify({ - type: messages.FOLLOW_CREATE_HINTS, - keysArray: produced, - newTab: this.state.newTab, - background: this.state.background, - }), '*'); - } - - remove() { - this.keys = []; - broadcastMessage(this.win, { - type: messages.FOLLOW_REMOVE_HINTS, - }); - } - - hintchars() { - return this.store.getState().setting.properties.hintchars; - } -} diff --git a/src/content/components/top-content/index.ts b/src/content/components/top-content/index.ts deleted file mode 100644 index ac95ea9..0000000 --- a/src/content/components/top-content/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import CommonComponent from '../common'; -import FollowController from './follow-controller'; -import FindComponent from './find'; -import * as consoleFrames from '../../console-frames'; -import * as messages from '../../../shared/messages'; -import MessageListener from '../../MessageListener'; -import * as scrolls from '../../scrolls'; - -export default class TopContent { - private win: Window; - - private store: any; - - constructor(win: Window, store: any) { - this.win = win; - this.store = store; - - new CommonComponent(win, store); // eslint-disable-line no-new - new FollowController(win, store); // eslint-disable-line no-new - new FindComponent(store); // eslint-disable-line no-new - - // TODO make component - consoleFrames.initialize(this.win.document); - - new MessageListener().onWebMessage(this.onWebMessage.bind(this)); - new MessageListener().onBackgroundMessage( - this.onBackgroundMessage.bind(this)); - } - - onWebMessage(message: messages.Message) { - switch (message.type) { - case messages.CONSOLE_UNFOCUS: - this.win.focus(); - consoleFrames.blur(window.document); - } - } - - onBackgroundMessage(message: messages.Message) { - let addonState = this.store.getState().addon; - - switch (message.type) { - case messages.ADDON_ENABLED_QUERY: - return Promise.resolve({ - type: messages.ADDON_ENABLED_RESPONSE, - enabled: addonState.enabled, - }); - case messages.TAB_SCROLL_TO: - return scrolls.scrollTo(message.x, message.y, false); - } - } -} diff --git a/src/content/console-frames.ts b/src/content/console-frames.ts deleted file mode 100644 index bd6b835..0000000 --- a/src/content/console-frames.ts +++ /dev/null @@ -1,38 +0,0 @@ -import * as messages from '../shared/messages'; - -const initialize = (doc: Document): HTMLIFrameElement => { - let iframe = doc.createElement('iframe'); - iframe.src = browser.runtime.getURL('build/console.html'); - iframe.id = 'vimvixen-console-frame'; - iframe.className = 'vimvixen-console-frame'; - doc.body.append(iframe); - - return iframe; -}; - -const blur = (doc: Document) => { - let ele = doc.getElementById('vimvixen-console-frame') as HTMLIFrameElement; - ele.blur(); -}; - -const postError = (text: string): Promise<any> => { - return browser.runtime.sendMessage({ - type: messages.CONSOLE_FRAME_MESSAGE, - message: { - type: messages.CONSOLE_SHOW_ERROR, - text, - }, - }); -}; - -const postInfo = (text: string): Promise<any> => { - return browser.runtime.sendMessage({ - type: messages.CONSOLE_FRAME_MESSAGE, - message: { - type: messages.CONSOLE_SHOW_INFO, - text, - }, - }); -}; - -export { initialize, blur, postError, postInfo }; diff --git a/src/content/controllers/AddonEnabledController.ts b/src/content/controllers/AddonEnabledController.ts new file mode 100644 index 0000000..4e19b6a --- /dev/null +++ b/src/content/controllers/AddonEnabledController.ts @@ -0,0 +1,19 @@ +import * as messages from '../../shared/messages'; +import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; + +export default class AddonEnabledController { + private addonEnabledUseCase: AddonEnabledUseCase; + + constructor({ + addonEnabledUseCase = new AddonEnabledUseCase(), + } = {}) { + this.addonEnabledUseCase = addonEnabledUseCase; + } + + getAddonEnabled( + _message: messages.AddonEnabledQueryMessage, + ): Promise<boolean> { + let enabled = this.addonEnabledUseCase.getEnabled(); + return Promise.resolve(enabled); + } +} diff --git a/src/content/controllers/ConsoleFrameController.ts b/src/content/controllers/ConsoleFrameController.ts new file mode 100644 index 0000000..fafadf4 --- /dev/null +++ b/src/content/controllers/ConsoleFrameController.ts @@ -0,0 +1,16 @@ +import ConsoleFrameUseCase from '../usecases/ConsoleFrameUseCase'; +import * as messages from '../../shared/messages'; + +export default class ConsoleFrameController { + private consoleFrameUseCase: ConsoleFrameUseCase; + + constructor({ + consoleFrameUseCase = new ConsoleFrameUseCase(), + } = {}) { + this.consoleFrameUseCase = consoleFrameUseCase; + } + + unfocus(_message: messages.Message) { + this.consoleFrameUseCase.unfocus(); + } +} diff --git a/src/content/controllers/FindController.ts b/src/content/controllers/FindController.ts new file mode 100644 index 0000000..cf27a8d --- /dev/null +++ b/src/content/controllers/FindController.ts @@ -0,0 +1,24 @@ +import * as messages from '../../shared/messages'; +import FindUseCase from '../usecases/FindUseCase'; + +export default class FindController { + private findUseCase: FindUseCase; + + constructor({ + findUseCase = new FindUseCase(), + } = {}) { + this.findUseCase = findUseCase; + } + + async start(m: messages.ConsoleEnterFindMessage): Promise<void> { + await this.findUseCase.startFind(m.text); + } + + async next(_: messages.FindNextMessage): Promise<void> { + await this.findUseCase.findNext(); + } + + async prev(_: messages.FindPrevMessage): Promise<void> { + await this.findUseCase.findPrev(); + } +} 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 new file mode 100644 index 0000000..20c24c0 --- /dev/null +++ b/src/content/controllers/KeymapController.ts @@ -0,0 +1,148 @@ +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 MarkKeyyUseCase from '../usecases/MarkKeyUseCase'; +import FollowMasterClient, { FollowMasterClientImpl } + from '../client/FollowMasterClient'; +import Key from '../domains/Key'; + +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; + + private markKeyUseCase: MarkKeyyUseCase; + + private followMasterClient: FollowMasterClient; + + 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(), + markKeyUseCase = new MarkKeyyUseCase(), + followMasterClient = new FollowMasterClientImpl(window.top), + } = {}) { + this.keymapUseCase = keymapUseCase; + this.addonEnabledUseCase = addonEnabledUseCase; + this.findSlaveUseCase = findSlaveUseCase; + this.scrollUseCase = scrollUseCase; + this.navigateUseCase = navigateUseCase; + this.focusUseCase = focusUseCase; + this.clipbaordUseCase = clipbaordUseCase; + this.backgroundClient = backgroundClient; + this.markKeyUseCase = markKeyUseCase; + this.followMasterClient = followMasterClient; + } + + // 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: + this.followMasterClient.startFollow(op.newTab, op.background); + break; + case operations.MARK_SET_PREFIX: + this.markKeyUseCase.enableSetMode(); + break; + case operations.MARK_JUMP_PREFIX: + this.markKeyUseCase.enableJumpMode(); + break; + 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/controllers/MarkController.ts b/src/content/controllers/MarkController.ts new file mode 100644 index 0000000..365794c --- /dev/null +++ b/src/content/controllers/MarkController.ts @@ -0,0 +1,16 @@ +import * as messages from '../../shared/messages'; +import MarkUseCase from '../usecases/MarkUseCase'; + +export default class MarkController { + private markUseCase: MarkUseCase; + + constructor({ + markUseCase = new MarkUseCase(), + } = {}) { + this.markUseCase = markUseCase; + } + + scrollTo(message: messages.TabScrollToMessage) { + this.markUseCase.scroll(message.x, message.y); + } +} diff --git a/src/content/controllers/MarkKeyController.ts b/src/content/controllers/MarkKeyController.ts new file mode 100644 index 0000000..395dee3 --- /dev/null +++ b/src/content/controllers/MarkKeyController.ts @@ -0,0 +1,31 @@ +import MarkUseCase from '../usecases/MarkUseCase'; +import MarkKeyyUseCase from '../usecases/MarkKeyUseCase'; +import Key from '../domains/Key'; + +export default class MarkKeyController { + private markUseCase: MarkUseCase; + + private markKeyUseCase: MarkKeyyUseCase; + + constructor({ + markUseCase = new MarkUseCase(), + markKeyUseCase = new MarkKeyyUseCase(), + } = {}) { + this.markUseCase = markUseCase; + this.markKeyUseCase = markKeyUseCase; + } + + press(key: Key): boolean { + if (this.markKeyUseCase.isSetMode()) { + this.markUseCase.set(key.key); + this.markKeyUseCase.disableSetMode(); + return true; + } + if (this.markKeyUseCase.isJumpMode()) { + this.markUseCase.jump(key.key); + this.markKeyUseCase.disableJumpMode(); + return true; + } + return false; + } +} diff --git a/src/content/controllers/SettingController.ts b/src/content/controllers/SettingController.ts new file mode 100644 index 0000000..f0e770b --- /dev/null +++ b/src/content/controllers/SettingController.ts @@ -0,0 +1,41 @@ +import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; +import SettingUseCase from '../usecases/SettingUseCase'; +import * as blacklists from '../../shared/blacklists'; + +import * as messages from '../../shared/messages'; + +export default class SettingController { + private addonEnabledUseCase: AddonEnabledUseCase; + + private settingUseCase: SettingUseCase; + + constructor({ + addonEnabledUseCase = new AddonEnabledUseCase(), + settingUseCase = new SettingUseCase(), + } = {}) { + this.addonEnabledUseCase = addonEnabledUseCase; + this.settingUseCase = settingUseCase; + } + + async initSettings(): Promise<void> { + try { + let current = await this.settingUseCase.reload(); + let disabled = blacklists.includes( + current.blacklist, window.location.href, + ); + if (disabled) { + this.addonEnabledUseCase.disable(); + } else { + this.addonEnabledUseCase.enable(); + } + } catch (e) { + // Sometime sendMessage fails when background script is not ready. + console.warn(e); + setTimeout(() => this.initSettings(), 500); + } + } + + async reloadSettings(_message: messages.Message): Promise<void> { + await this.settingUseCase.reload(); + } +} diff --git a/src/shared/utils/keys.ts b/src/content/domains/Key.ts index e9b0365..fbbb4bb 100644 --- a/src/shared/utils/keys.ts +++ b/src/content/domains/Key.ts @@ -1,9 +1,11 @@ -export interface Key { - key: string; - shiftKey: boolean | undefined; - ctrlKey: boolean | undefined; - altKey: boolean | undefined; - metaKey: boolean | undefined; +export default interface Key { + key: string; + shiftKey?: boolean; + ctrlKey?: boolean; + altKey?: boolean; + metaKey?: boolean; + + // eslint-disable-next-line semi } const modifiedKeyName = (name: string): string => { @@ -18,7 +20,7 @@ const modifiedKeyName = (name: string): string => { return name; }; -const fromKeyboardEvent = (e: KeyboardEvent): Key => { +export const fromKeyboardEvent = (e: KeyboardEvent): Key => { let key = modifiedKeyName(e.key); let shift = e.shiftKey; if (key.length === 1 && key.toUpperCase() === key.toLowerCase()) { @@ -36,7 +38,7 @@ const fromKeyboardEvent = (e: KeyboardEvent): Key => { }; }; -const fromMapKey = (key: string): Key => { +export const fromMapKey = (key: string): Key => { if (key.startsWith('<') && key.endsWith('>')) { let inner = key.slice(1, -1); let shift = inner.includes('S-'); @@ -63,37 +65,10 @@ const fromMapKey = (key: string): Key => { }; }; -const fromMapKeys = (keys: string): Key[] => { - const fromMapKeysRecursive = ( - remainings: string, mappedKeys: Key[], - ): Key[] => { - if (remainings.length === 0) { - return mappedKeys; - } - - let nextPos = 1; - if (remainings.startsWith('<')) { - let ltPos = remainings.indexOf('>'); - if (ltPos > 0) { - nextPos = ltPos + 1; - } - } - - return fromMapKeysRecursive( - remainings.slice(nextPos), - mappedKeys.concat([fromMapKey(remainings.slice(0, nextPos))]) - ); - }; - - return fromMapKeysRecursive(keys, []); -}; - -const equals = (e1: Key, e2: Key): boolean => { +export const equals = (e1: Key, e2: Key): boolean => { return e1.key === e2.key && e1.ctrlKey === e2.ctrlKey && e1.metaKey === e2.metaKey && e1.altKey === e2.altKey && e1.shiftKey === e2.shiftKey; }; - -export { fromKeyboardEvent, fromMapKey, fromMapKeys, equals }; diff --git a/src/content/domains/KeySequence.ts b/src/content/domains/KeySequence.ts new file mode 100644 index 0000000..6a05c2f --- /dev/null +++ b/src/content/domains/KeySequence.ts @@ -0,0 +1,64 @@ +import Key, * as keyUtils from './Key'; + +export default class KeySequence { + private keys: Key[]; + + private constructor(keys: Key[]) { + this.keys = keys; + } + + static from(keys: Key[]): KeySequence { + return new KeySequence(keys); + } + + push(key: Key): number { + return this.keys.push(key); + } + + length(): number { + return this.keys.length; + } + + startsWith(o: KeySequence): boolean { + if (this.keys.length < o.keys.length) { + return false; + } + for (let i = 0; i < o.keys.length; ++i) { + if (!keyUtils.equals(this.keys[i], o.keys[i])) { + return false; + } + } + return true; + } + + getKeyArray(): Key[] { + return this.keys; + } +} + +export const fromMapKeys = (keys: string): KeySequence => { + const fromMapKeysRecursive = ( + remainings: string, mappedKeys: Key[], + ): Key[] => { + if (remainings.length === 0) { + return mappedKeys; + } + + let nextPos = 1; + if (remainings.startsWith('<')) { + let ltPos = remainings.indexOf('>'); + if (ltPos > 0) { + nextPos = ltPos + 1; + } + } + + return fromMapKeysRecursive( + remainings.slice(nextPos), + mappedKeys.concat([keyUtils.fromMapKey(remainings.slice(0, nextPos))]) + ); + }; + + let data = fromMapKeysRecursive(keys, []); + return KeySequence.from(data); +}; + diff --git a/src/content/Mark.ts b/src/content/domains/Mark.ts index f1282fc..f1282fc 100644 --- a/src/content/Mark.ts +++ b/src/content/domains/Mark.ts diff --git a/src/content/focuses.ts b/src/content/focuses.ts deleted file mode 100644 index 8f53881..0000000 --- a/src/content/focuses.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as doms from '../shared/utils/dom'; - -const focusInput = (): void => { - 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(); - } else if (target instanceof HTMLTextAreaElement) { - target.focus(); - } -}; - -export { focusInput }; diff --git a/src/content/index.ts b/src/content/index.ts index 9d791fc..660ebf5 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,16 +1,16 @@ -import TopContentComponent from './components/top-content'; -import FrameContentComponent from './components/frame-content'; +import { ConsoleFramePresenterImpl } from './presenters/ConsoleFramePresenter'; import consoleFrameStyle from './site-style'; -import { newStore } from './store'; - -const store = newStore(); +import * as routes from './routes'; if (window.self === window.top) { - new TopContentComponent(window, store); // eslint-disable-line no-new -} else { - new FrameContentComponent(window, store); // eslint-disable-line no-new + routes.routeMasterComponents(); + + new ConsoleFramePresenterImpl().initialize(); } +routes.routeComponents(); + + let style = window.document.createElement('style'); style.textContent = consoleFrameStyle; window.document.head.appendChild(style); diff --git a/src/content/navigates.ts b/src/content/navigates.ts deleted file mode 100644 index a2007a6..0000000 --- a/src/content/navigates.ts +++ /dev/null @@ -1,83 +0,0 @@ -const REL_PATTERN: {[key: string]: RegExp} = { - prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i, - next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i, -}; - -// Return the last element in the document matching the supplied selector -// and the optional filter, or null if there are no matches. -// eslint-disable-next-line func-style -function selectLast<E extends Element>( - win: Window, - selector: string, - filter?: (e: E) => boolean, -): E | null { - let nodes = Array.from( - win.document.querySelectorAll(selector) as NodeListOf<E> - ); - - if (filter) { - nodes = nodes.filter(filter); - } - return nodes.length ? nodes[nodes.length - 1] : null; -} - -const historyPrev = (win: Window): void => { - win.history.back(); -}; - -const historyNext = (win: Window): void => { - win.history.forward(); -}; - -// Code common to linkPrev and linkNext which navigates to the specified page. -const linkRel = (win: Window, rel: string): void => { - let link = selectLast<HTMLLinkElement>(win, `link[rel~=${rel}][href]`); - if (link) { - win.location.href = link.href; - return; - } - - const pattern = REL_PATTERN[rel]; - - let a = selectLast<HTMLAnchorElement>(win, `a[rel~=${rel}][href]`) || - // `innerText` is much slower than `textContent`, but produces much better - // (i.e. less unexpected) results - selectLast(win, 'a[href]', lnk => pattern.test(lnk.innerText)); - - if (a) { - a.click(); - } -}; - -const linkPrev = (win: Window): void => { - linkRel(win, 'prev'); -}; - -const linkNext = (win: Window): void => { - linkRel(win, 'next'); -}; - -const parent = (win: Window): void => { - const loc = win.location; - if (loc.hash !== '') { - loc.hash = ''; - return; - } else if (loc.search !== '') { - loc.search = ''; - return; - } - - const basenamePattern = /\/[^/]+$/; - const lastDirPattern = /\/[^/]+\/$/; - if (basenamePattern.test(loc.pathname)) { - loc.pathname = loc.pathname.replace(basenamePattern, '/'); - } else if (lastDirPattern.test(loc.pathname)) { - loc.pathname = loc.pathname.replace(lastDirPattern, '/'); - } -}; - -const root = (win: Window): void => { - win.location.href = win.location.origin; -}; - -export { historyPrev, historyNext, linkPrev, linkNext, parent, root }; diff --git a/src/content/presenters/ConsoleFramePresenter.ts b/src/content/presenters/ConsoleFramePresenter.ts new file mode 100644 index 0000000..3c7477b --- /dev/null +++ b/src/content/presenters/ConsoleFramePresenter.ts @@ -0,0 +1,25 @@ +export default interface ConsoleFramePresenter { + initialize(): void; + + blur(): void; + + // eslint-disable-next-line semi +} + +export class ConsoleFramePresenterImpl implements ConsoleFramePresenter { + initialize(): void { + let iframe = document.createElement('iframe'); + iframe.src = browser.runtime.getURL('build/console.html'); + iframe.id = 'vimvixen-console-frame'; + iframe.className = 'vimvixen-console-frame'; + document.body.append(iframe); + } + + blur(): void { + let ele = document.getElementById('vimvixen-console-frame'); + if (!ele) { + throw new Error('console frame not created'); + } + ele.blur(); + } +} diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts new file mode 100644 index 0000000..d9bc835 --- /dev/null +++ b/src/content/presenters/FindPresenter.ts @@ -0,0 +1,52 @@ + +export default interface FindPresenter { + find(keyword: string, backwards: boolean): boolean; + + clearSelection(): void; + + // eslint-disable-next-line semi +} + +// window.find(aString, aCaseSensitive, aBackwards, aWrapAround, +// aWholeWord, aSearchInFrames); +// +// NOTE: window.find is not standard API +// https://developer.mozilla.org/en-US/docs/Web/API/Window/find +interface MyWindow extends Window { + find( + aString: string, + aCaseSensitive?: boolean, + aBackwards?: boolean, + aWrapAround?: boolean, + aWholeWord?: boolean, + aSearchInFrames?: boolean, + aShowDialog?: boolean): boolean; +} + +// eslint-disable-next-line no-var, vars-on-top, init-declarations +declare var window: MyWindow; + +export class FindPresenterImpl implements FindPresenter { + find(keyword: string, backwards: boolean): boolean { + let caseSensitive = false; + let wrapScan = true; + + + // NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work + // because of same origin policy + let found = window.find(keyword, caseSensitive, backwards, wrapScan); + if (found) { + return found; + } + this.clearSelection(); + + return window.find(keyword, caseSensitive, backwards, wrapScan); + } + + clearSelection(): void { + let sel = window.getSelection(); + if (sel) { + sel.removeAllRanges(); + } + } +} 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/presenters/FollowPresenter.ts b/src/content/presenters/FollowPresenter.ts new file mode 100644 index 0000000..f0d115c --- /dev/null +++ b/src/content/presenters/FollowPresenter.ts @@ -0,0 +1,134 @@ +import Hint, { InputHint, LinkHint } from './Hint'; +import * as doms from '../../shared/utils/dom'; + +const TARGET_SELECTOR = [ + 'a', 'button', 'input', 'textarea', 'area', + '[contenteditable=true]', '[contenteditable=""]', '[tabindex]', + '[role="button"]', 'summary' +].join(','); + +interface Size { + width: number; + height: number; +} + +interface Point { + x: number; + y: number; +} + +const inViewport = ( + win: Window, + element: Element, + viewSize: Size, + framePosition: Point, +): boolean => { + let { + top, left, bottom, right + } = doms.viewportRect(element); + let doc = win.document; + let frameWidth = doc.documentElement.clientWidth; + let frameHeight = doc.documentElement.clientHeight; + + if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) { + // out of frame + return false; + } + if (right + framePosition.x < 0 || bottom + framePosition.y < 0 || + left + framePosition.x > viewSize.width || + top + framePosition.y > viewSize.height) { + // out of viewport + return false; + } + return true; +}; + +const isAriaHiddenOrAriaDisabled = (win: Window, element: Element): boolean => { + if (!element || win.document.documentElement === element) { + return false; + } + for (let attr of ['aria-hidden', 'aria-disabled']) { + let value = element.getAttribute(attr); + if (value !== null) { + let hidden = value.toLowerCase(); + if (hidden === '' || hidden === 'true') { + return true; + } + } + } + return isAriaHiddenOrAriaDisabled(win, element.parentElement as Element); +}; + +export default interface FollowPresenter { + getTargetCount(viewSize: Size, framePosition: Point): number; + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void; + + filterHints(prefix: string): void; + + clearHints(): void; + + getHint(tag: string): Hint | undefined; + + // eslint-disable-next-line semi +} + +export class FollowPresenterImpl implements FollowPresenter { + private hints: Hint[] + + constructor() { + this.hints = []; + } + + getTargetCount(viewSize: Size, framePosition: Point): number { + let targets = this.getTargets(viewSize, framePosition); + return targets.length; + } + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void { + let targets = this.getTargets(viewSize, framePosition); + let min = Math.min(targets.length, tags.length); + for (let i = 0; i < min; ++i) { + let target = targets[i]; + if (target instanceof HTMLAnchorElement || + target instanceof HTMLAreaElement) { + this.hints.push(new LinkHint(target, tags[i])); + } else { + this.hints.push(new InputHint(target, tags[i])); + } + } + } + + filterHints(prefix: string): void { + let shown = this.hints.filter(h => h.getTag().startsWith(prefix)); + let hidden = this.hints.filter(h => !h.getTag().startsWith(prefix)); + + shown.forEach(h => h.show()); + hidden.forEach(h => h.hide()); + } + + clearHints(): void { + this.hints.forEach(h => h.remove()); + this.hints = []; + } + + getHint(tag: string): Hint | undefined { + return this.hints.find(h => h.getTag() === tag); + } + + private getTargets(viewSize: Size, framePosition: Point): HTMLElement[] { + let all = window.document.querySelectorAll(TARGET_SELECTOR); + let filtered = Array.prototype.filter.call(all, (element: HTMLElement) => { + let style = window.getComputedStyle(element); + + // AREA's 'display' in Browser style is 'none' + return (element.tagName === 'AREA' || style.display !== 'none') && + style.visibility !== 'hidden' && + (element as HTMLInputElement).type !== 'hidden' && + element.offsetHeight > 0 && + !isAriaHiddenOrAriaDisabled(window, element) && + inViewport(window, element, viewSize, framePosition); + }); + return filtered; + } +} diff --git a/src/content/presenters/Hint.ts b/src/content/presenters/Hint.ts new file mode 100644 index 0000000..60c0f4c --- /dev/null +++ b/src/content/presenters/Hint.ts @@ -0,0 +1,127 @@ +import * as doms from '../../shared/utils/dom'; + +interface Point { + x: number; + y: number; +} + +const hintPosition = (element: Element): Point => { + let { left, top, right, bottom } = doms.viewportRect(element); + + if (element.tagName !== 'AREA') { + return { x: left, y: top }; + } + + return { + x: (left + right) / 2, + y: (top + bottom) / 2, + }; +}; + +export default abstract class Hint { + private hint: HTMLElement; + + private tag: string; + + constructor(target: HTMLElement, tag: string) { + this.tag = tag; + + let doc = target.ownerDocument; + if (doc === null) { + throw new TypeError('ownerDocument is null'); + } + + let { x, y } = hintPosition(target); + let { scrollX, scrollY } = window; + + let hint = doc.createElement('span'); + hint.className = 'vimvixen-hint'; + hint.textContent = tag; + hint.style.left = x + scrollX + 'px'; + hint.style.top = y + scrollY + 'px'; + + doc.body.append(hint); + + this.hint = hint; + this.show(); + } + + show(): void { + this.hint.style.display = 'inline'; + } + + hide(): void { + this.hint.style.display = 'none'; + } + + remove(): void { + this.hint.remove(); + } + + getTag(): string { + return this.tag; + } +} + +export class LinkHint extends Hint { + private target: HTMLAnchorElement | HTMLAreaElement; + + constructor(target: HTMLAnchorElement | HTMLAreaElement, tag: string) { + super(target, tag); + + this.target = target; + } + + getLink(): string { + return this.target.href; + } + + getLinkTarget(): string | null { + return this.target.getAttribute('target'); + } + + click(): void { + this.target.click(); + } +} + +export class InputHint extends Hint { + private target: HTMLElement; + + constructor(target: HTMLElement, tag: string) { + super(target, tag); + + this.target = target; + } + + activate(): void { + let target = this.target; + switch (target.tagName.toLowerCase()) { + case 'input': + switch ((target as HTMLInputElement).type) { + case 'file': + case 'checkbox': + case 'radio': + case 'submit': + case 'reset': + case 'button': + case 'image': + case 'color': + return target.click(); + default: + return target.focus(); + } + case 'textarea': + return target.focus(); + case 'button': + case 'summary': + return target.click(); + default: + if (doms.isContentEditable(target)) { + return target.focus(); + } else if (target.hasAttribute('tabindex')) { + return target.click(); + } + } + } +} diff --git a/src/content/presenters/NavigationPresenter.ts b/src/content/presenters/NavigationPresenter.ts new file mode 100644 index 0000000..66110e5 --- /dev/null +++ b/src/content/presenters/NavigationPresenter.ts @@ -0,0 +1,98 @@ +export default interface NavigationPresenter { + openHistoryPrev(): void; + + openHistoryNext(): void; + + openLinkPrev(): void; + + openLinkNext(): void; + + openParent(): void; + + openRoot(): void; + + // eslint-disable-next-line semi +} + +const REL_PATTERN: {[key: string]: RegExp} = { + prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i, + next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i, +}; + +// Return the last element in the document matching the supplied selector +// and the optional filter, or null if there are no matches. +// eslint-disable-next-line func-style +function selectLast<E extends Element>( + selector: string, + filter?: (e: E) => boolean, +): E | null { + let nodes = Array.from( + window.document.querySelectorAll(selector) as NodeListOf<E> + ); + + if (filter) { + nodes = nodes.filter(filter); + } + return nodes.length ? nodes[nodes.length - 1] : null; +} + +export class NavigationPresenterImpl implements NavigationPresenter { + openHistoryPrev(): void { + window.history.back(); + } + + openHistoryNext(): void { + window.history.forward(); + } + + openLinkPrev(): void { + this.linkRel('prev'); + } + + openLinkNext(): void { + this.linkRel('next'); + } + + openParent(): void { + const loc = window.location; + if (loc.hash !== '') { + loc.hash = ''; + return; + } else if (loc.search !== '') { + loc.search = ''; + return; + } + + const basenamePattern = /\/[^/]+$/; + const lastDirPattern = /\/[^/]+\/$/; + if (basenamePattern.test(loc.pathname)) { + loc.pathname = loc.pathname.replace(basenamePattern, '/'); + } else if (lastDirPattern.test(loc.pathname)) { + loc.pathname = loc.pathname.replace(lastDirPattern, '/'); + } + } + + openRoot(): void { + window.location.href = window.location.origin; + } + + // Code common to linkPrev and linkNext which navigates to the specified page. + private linkRel(rel: 'prev' | 'next'): void { + let link = selectLast<HTMLLinkElement>(`link[rel~=${rel}][href]`); + if (link) { + window.location.href = link.href; + return; + } + + const pattern = REL_PATTERN[rel]; + + let a = selectLast<HTMLAnchorElement>(`a[rel~=${rel}][href]`) || + // `innerText` is much slower than `textContent`, but produces much better + // (i.e. less unexpected) results + selectLast('a[href]', lnk => pattern.test(lnk.innerText)); + + if (a) { + a.click(); + } + } +} diff --git a/src/content/scrolls.ts b/src/content/presenters/ScrollPresenter.ts index 6a35315..9286fb0 100644 --- a/src/content/scrolls.ts +++ b/src/content/presenters/ScrollPresenter.ts @@ -1,4 +1,4 @@ -import * as doms from '../shared/utils/dom'; +import * as doms from '../../shared/utils/dom'; const SCROLL_DELTA_X = 64; const SCROLL_DELTA_Y = 64; @@ -94,75 +94,86 @@ class Scroller { } } -const getScroll = () => { - let target = scrollTarget(); - return { x: target.scrollLeft, y: target.scrollTop }; -}; +export type Point = { x: number, y: number }; + +export default interface ScrollPresenter { + getScroll(): Point; + scrollVertically(amount: number, smooth: boolean): void; + scrollHorizonally(amount: number, smooth: boolean): void; + scrollPages(amount: number, smooth: boolean): void; + scrollTo(x: number, y: number, smooth: boolean): void; + scrollToTop(smooth: boolean): void; + scrollToBottom(smooth: boolean): void; + scrollToHome(smooth: boolean): void; + scrollToEnd(smooth: boolean): void; + + // eslint-disable-next-line semi +} -const scrollVertically = (count: number, smooth: boolean): void => { - let target = scrollTarget(); - let delta = SCROLL_DELTA_Y * count; - if (scrolling) { - delta = SCROLL_DELTA_Y * count * 4; +export class ScrollPresenterImpl { + getScroll(): Point { + let target = scrollTarget(); + return { x: target.scrollLeft, y: target.scrollTop }; } - new Scroller(target, smooth).scrollBy(0, delta); -}; -const scrollHorizonally = (count: number, smooth: boolean): void => { - let target = scrollTarget(); - let delta = SCROLL_DELTA_X * count; - if (scrolling) { - delta = SCROLL_DELTA_X * count * 4; + scrollVertically(count: number, smooth: boolean): void { + let target = scrollTarget(); + let delta = SCROLL_DELTA_Y * count; + if (scrolling) { + delta = SCROLL_DELTA_Y * count * 4; + } + new Scroller(target, smooth).scrollBy(0, delta); } - new Scroller(target, smooth).scrollBy(delta, 0); -}; -const scrollPages = (count: number, smooth: boolean): void => { - let target = scrollTarget(); - let height = target.clientHeight; - let delta = height * count; - if (scrolling) { - delta = height * count; + scrollHorizonally(count: number, smooth: boolean): void { + let target = scrollTarget(); + let delta = SCROLL_DELTA_X * count; + if (scrolling) { + delta = SCROLL_DELTA_X * count * 4; + } + new Scroller(target, smooth).scrollBy(delta, 0); } - new Scroller(target, smooth).scrollBy(0, delta); -}; -const scrollTo = (x: number, y: number, smooth: boolean): void => { - let target = scrollTarget(); - new Scroller(target, smooth).scrollTo(x, y); -}; + scrollPages(count: number, smooth: boolean): void { + let target = scrollTarget(); + let height = target.clientHeight; + let delta = height * count; + if (scrolling) { + delta = height * count; + } + new Scroller(target, smooth).scrollBy(0, delta); + } -const scrollToTop = (smooth: boolean): void => { - let target = scrollTarget(); - let x = target.scrollLeft; - let y = 0; - new Scroller(target, smooth).scrollTo(x, y); -}; + scrollTo(x: number, y: number, smooth: boolean): void { + let target = scrollTarget(); + new Scroller(target, smooth).scrollTo(x, y); + } -const scrollToBottom = (smooth: boolean): void => { - let target = scrollTarget(); - let x = target.scrollLeft; - let y = target.scrollHeight; - new Scroller(target, smooth).scrollTo(x, y); -}; + scrollToTop(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollLeft; + let y = 0; + new Scroller(target, smooth).scrollTo(x, y); + } -const scrollToHome = (smooth: boolean): void => { - let target = scrollTarget(); - let x = 0; - let y = target.scrollTop; - new Scroller(target, smooth).scrollTo(x, y); -}; + scrollToBottom(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollLeft; + let y = target.scrollHeight; + new Scroller(target, smooth).scrollTo(x, y); + } -const scrollToEnd = (smooth: boolean): void => { - let target = scrollTarget(); - let x = target.scrollWidth; - let y = target.scrollTop; - new Scroller(target, smooth).scrollTo(x, y); -}; + scrollToHome(smooth: boolean): void { + let target = scrollTarget(); + let x = 0; + let y = target.scrollTop; + new Scroller(target, smooth).scrollTo(x, y); + } -export { - getScroll, - scrollVertically, scrollHorizonally, scrollPages, - scrollTo, - scrollToTop, scrollToBottom, scrollToHome, scrollToEnd -}; + scrollToEnd(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollWidth; + let y = target.scrollTop; + new Scroller(target, smooth).scrollTo(x, y); + } +} diff --git a/src/content/reducers/addon.ts b/src/content/reducers/addon.ts deleted file mode 100644 index 2131228..0000000 --- a/src/content/reducers/addon.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as actions from '../actions'; - -export interface State { - enabled: boolean; -} - -const defaultState: State = { - enabled: true, -}; - -export default function reducer( - state: State = defaultState, - action: actions.AddonAction, -): State { - switch (action.type) { - case actions.ADDON_SET_ENABLED: - return { ...state, - enabled: action.enabled, }; - default: - return state; - } -} diff --git a/src/content/reducers/find.ts b/src/content/reducers/find.ts deleted file mode 100644 index 8c3e637..0000000 --- a/src/content/reducers/find.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as actions from '../actions'; - -export interface State { - keyword: string | null; - found: boolean; -} - -const defaultState: State = { - keyword: null, - found: false, -}; - -export default function reducer( - state: State = defaultState, - action: actions.FindAction, -): State { - switch (action.type) { - case actions.FIND_SET_KEYWORD: - return { ...state, - keyword: action.keyword, - found: action.found, }; - default: - return state; - } -} diff --git a/src/content/reducers/follow-controller.ts b/src/content/reducers/follow-controller.ts deleted file mode 100644 index 6965704..0000000 --- a/src/content/reducers/follow-controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as actions from '../actions'; - -export interface State { - enabled: boolean; - newTab: boolean; - background: boolean; - keys: string, -} - -const defaultState: State = { - enabled: false, - newTab: false, - background: false, - keys: '', -}; - -export default function reducer( - state: State = defaultState, - action: actions.FollowAction, -): State { - switch (action.type) { - case actions.FOLLOW_CONTROLLER_ENABLE: - return { ...state, - enabled: true, - newTab: action.newTab, - background: action.background, - keys: '', }; - case actions.FOLLOW_CONTROLLER_DISABLE: - return { ...state, - enabled: false, }; - case actions.FOLLOW_CONTROLLER_KEY_PRESS: - return { ...state, - keys: state.keys + action.key, }; - case actions.FOLLOW_CONTROLLER_BACKSPACE: - return { ...state, - keys: state.keys.slice(0, -1), }; - default: - return state; - } -} diff --git a/src/content/reducers/index.ts b/src/content/reducers/index.ts deleted file mode 100644 index fb5eb84..0000000 --- a/src/content/reducers/index.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { combineReducers } from 'redux'; -import addon, { State as AddonState } from './addon'; -import find, { State as FindState } from './find'; -import setting, { State as SettingState } from './setting'; -import input, { State as InputState } from './input'; -import followController, { State as FollowControllerState } - from './follow-controller'; -import mark, { State as MarkState } from './mark'; - -export interface State { - addon: AddonState; - find: FindState; - setting: SettingState; - input: InputState; - followController: FollowControllerState; - mark: MarkState; -} - -export default combineReducers({ - addon, find, setting, input, followController, mark, -}); diff --git a/src/content/reducers/input.ts b/src/content/reducers/input.ts deleted file mode 100644 index 35b9075..0000000 --- a/src/content/reducers/input.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as actions from '../actions'; -import * as keyUtils from '../../shared/utils/keys'; - -export interface State { - keys: keyUtils.Key[], -} - -const defaultState: State = { - keys: [] -}; - -export default function reducer( - state: State = defaultState, - action: actions.InputAction, -): State { - switch (action.type) { - case actions.INPUT_KEY_PRESS: - return { ...state, - keys: state.keys.concat([action.key]), }; - case actions.INPUT_CLEAR_KEYS: - return { ...state, - keys: [], }; - default: - return state; - } -} diff --git a/src/content/reducers/mark.ts b/src/content/reducers/mark.ts deleted file mode 100644 index 7409938..0000000 --- a/src/content/reducers/mark.ts +++ /dev/null @@ -1,35 +0,0 @@ -import Mark from '../Mark'; -import * as actions from '../actions'; - -export interface State { - setMode: boolean; - jumpMode: boolean; - marks: { [key: string]: Mark }; -} - -const defaultState: State = { - setMode: false, - jumpMode: false, - marks: {}, -}; - -export default function reducer( - state: State = defaultState, - action: actions.MarkAction, -): State { - switch (action.type) { - case actions.MARK_START_SET: - return { ...state, setMode: true }; - case actions.MARK_START_JUMP: - return { ...state, jumpMode: true }; - case actions.MARK_CANCEL: - return { ...state, setMode: false, jumpMode: false }; - case actions.MARK_SET_LOCAL: { - let marks = { ...state.marks }; - marks[action.key] = { x: action.x, y: action.y }; - return { ...state, setMode: false, marks }; - } - default: - return state; - } -} diff --git a/src/content/reducers/setting.ts b/src/content/reducers/setting.ts deleted file mode 100644 index 9ca1380..0000000 --- a/src/content/reducers/setting.ts +++ /dev/null @@ -1,40 +0,0 @@ -import * as actions from '../actions'; -import * as keyUtils from '../../shared/utils/keys'; -import * as operations from '../../shared/operations'; -import { Search, Properties, DefaultSetting } from '../../shared/Settings'; - -export interface State { - keymaps: { key: keyUtils.Key[], op: operations.Operation }[]; - search: Search; - properties: Properties; -} - -// defaultState does not refer due to the state is load from -// background on load. -const defaultState: State = { - keymaps: [], - search: DefaultSetting.search, - properties: DefaultSetting.properties, -}; - -export default function reducer( - state: State = defaultState, - action: actions.SettingAction, -): State { - switch (action.type) { - case actions.SETTING_SET: - return { - keymaps: Object.entries(action.settings.keymaps).map((entry) => { - return { - key: keyUtils.fromMapKeys(entry[0]), - op: entry[1], - }; - }), - properties: action.settings.properties, - search: action.settings.search, - }; - default: - return state; - } -} - diff --git a/src/content/repositories/AddonEnabledRepository.ts b/src/content/repositories/AddonEnabledRepository.ts new file mode 100644 index 0000000..4eaabb1 --- /dev/null +++ b/src/content/repositories/AddonEnabledRepository.ts @@ -0,0 +1,19 @@ +let enabled: boolean = false; + +export default interface AddonEnabledRepository { + set(on: boolean): void; + + get(): boolean; + + // eslint-disable-next-line semi +} + +export class AddonEnabledRepositoryImpl implements AddonEnabledRepository { + set(on: boolean): void { + enabled = on; + } + + get(): boolean { + return enabled; + } +} diff --git a/src/content/repositories/ClipboardRepository.ts b/src/content/repositories/ClipboardRepository.ts new file mode 100644 index 0000000..747ae6a --- /dev/null +++ b/src/content/repositories/ClipboardRepository.ts @@ -0,0 +1,46 @@ +export default interface ClipboardRepository { + read(): string; + + write(text: string): void; + + // eslint-disable-next-line semi +} + +export class ClipboardRepositoryImpl { + read(): string { + let textarea = window.document.createElement('textarea'); + window.document.body.append(textarea); + + textarea.style.position = 'fixed'; + textarea.style.top = '-100px'; + textarea.contentEditable = 'true'; + textarea.focus(); + + let ok = window.document.execCommand('paste'); + let value = textarea.textContent!!; + textarea.remove(); + + if (!ok) { + throw new Error('failed to access clipbaord'); + } + + return value; + } + + write(text: string): void { + let input = window.document.createElement('input'); + window.document.body.append(input); + + input.style.position = 'fixed'; + input.style.top = '-100px'; + input.value = text; + input.select(); + + let ok = window.document.execCommand('copy'); + input.remove(); + + if (!ok) { + throw new Error('failed to access clipbaord'); + } + } +} diff --git a/src/content/repositories/FindRepository.ts b/src/content/repositories/FindRepository.ts new file mode 100644 index 0000000..85eca40 --- /dev/null +++ b/src/content/repositories/FindRepository.ts @@ -0,0 +1,19 @@ +export default interface FindRepository { + getLastKeyword(): string | null; + + setLastKeyword(keyword: string): void; + + // eslint-disable-next-line semi +} + +let current: string | null = null; + +export class FindRepositoryImpl implements FindRepository { + getLastKeyword(): string | null { + return current; + } + + setLastKeyword(keyword: string): void { + current = keyword; + } +} 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/repositories/KeymapRepository.ts b/src/content/repositories/KeymapRepository.ts new file mode 100644 index 0000000..770ba0b --- /dev/null +++ b/src/content/repositories/KeymapRepository.ts @@ -0,0 +1,24 @@ +import Key from '../domains/Key'; +import KeySequence from '../domains/KeySequence'; + +export default interface KeymapRepository { + enqueueKey(key: Key): KeySequence; + + clear(): void; + + // eslint-disable-next-line semi +} + +let current: KeySequence = KeySequence.from([]); + +export class KeymapRepositoryImpl { + + enqueueKey(key: Key): KeySequence { + current.push(key); + return current; + } + + clear(): void { + current = KeySequence.from([]); + } +} diff --git a/src/content/repositories/MarkKeyRepository.ts b/src/content/repositories/MarkKeyRepository.ts new file mode 100644 index 0000000..c24548a --- /dev/null +++ b/src/content/repositories/MarkKeyRepository.ts @@ -0,0 +1,52 @@ +export default interface MarkKeyRepository { + isSetMode(): boolean; + + enableSetMode(): void; + + disabeSetMode(): void; + + isJumpMode(): boolean; + + enableJumpMode(): void; + + disabeJumpMode(): void; + + // eslint-disable-next-line semi +} + +interface Mode { + setMode: boolean; + jumpMode: boolean; +} + +let current: Mode = { + setMode: false, + jumpMode: false, +}; + +export class MarkKeyRepositoryImpl implements MarkKeyRepository { + + isSetMode(): boolean { + return current.setMode; + } + + enableSetMode(): void { + current.setMode = true; + } + + disabeSetMode(): void { + current.setMode = false; + } + + isJumpMode(): boolean { + return current.jumpMode; + } + + enableJumpMode(): void { + current.jumpMode = true; + } + + disabeJumpMode(): void { + current.jumpMode = false; + } +} diff --git a/src/content/repositories/MarkRepository.ts b/src/content/repositories/MarkRepository.ts new file mode 100644 index 0000000..ed5afe2 --- /dev/null +++ b/src/content/repositories/MarkRepository.ts @@ -0,0 +1,25 @@ +import Mark from '../domains/Mark'; + +export default interface MarkRepository { + set(key: string, mark: Mark): void; + + get(key: string): Mark | null; + + // eslint-disable-next-line semi +} + +const saved: {[key: string]: Mark} = {}; + +export class MarkRepositoryImpl implements MarkRepository { + set(key: string, mark: Mark): void { + saved[key] = mark; + } + + get(key: string): Mark | null { + let v = saved[key]; + if (!v) { + return null; + } + return { ...v }; + } +} diff --git a/src/content/repositories/SettingRepository.ts b/src/content/repositories/SettingRepository.ts new file mode 100644 index 0000000..711b2a2 --- /dev/null +++ b/src/content/repositories/SettingRepository.ts @@ -0,0 +1,21 @@ +import Settings, { DefaultSetting } from '../../shared/Settings'; + +let current: Settings = DefaultSetting; + +export default interface SettingRepository { + set(setting: Settings): void; + + get(): Settings; + + // eslint-disable-next-line semi +} + +export class SettingRepositoryImpl implements SettingRepository { + set(setting: Settings): void { + current = setting; + } + + get(): Settings { + return current; + } +} diff --git a/src/content/routes.ts b/src/content/routes.ts new file mode 100644 index 0000000..0bce4f5 --- /dev/null +++ b/src/content/routes.ts @@ -0,0 +1,97 @@ +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 InputDriver from './InputDriver'; +import KeymapController from './controllers/KeymapController'; +import AddonEnabledUseCase from './usecases/AddonEnabledUseCase'; +import MarkKeyController from './controllers/MarkKeyController'; +import AddonEnabledController from './controllers/AddonEnabledController'; +import SettingController from './controllers/SettingController'; +import ConsoleFrameController from './controllers/ConsoleFrameController'; +import * as messages from '../shared/messages'; + +export const routeComponents = () => { + let listener = new MessageListener(); + + 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 => followKeyController.press(key)); + inputDriver.onKey(key => markKeyController.press(key)); + inputDriver.onKey(key => keymapController.press(key)); + + let settingController = new SettingController(); + settingController.initSettings(); + + listener.onBackgroundMessage((message: messages.Message): any => { + let addonEnabledUseCase = new AddonEnabledUseCase(); + + switch (message.type) { + case messages.SETTINGS_CHANGED: + return settingController.reloadSettings(message); + case messages.ADDON_TOGGLE_ENABLED: + return addonEnabledUseCase.toggle(); + } + }); +}; + +export const routeMasterComponents = () => { + let listener = new MessageListener(); + + let findController = new FindController(); + let followMasterController = new FollowMasterController(); + let markController = new MarkController(); + let addonEnabledController = new AddonEnabledController(); + let consoleFrameController = new ConsoleFrameController(); + + listener.onWebMessage((message: messages.Message, sender: Window) => { + switch (message.type) { + case messages.CONSOLE_ENTER_FIND: + return findController.start(message); + case messages.FIND_NEXT: + return findController.next(message); + case messages.FIND_PREV: + return findController.prev(message); + case messages.CONSOLE_UNFOCUS: + return consoleFrameController.unfocus(message); + 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; + }); + + listener.onBackgroundMessage((message: messages.Message) => { + switch (message.type) { + case messages.ADDON_ENABLED_QUERY: + return addonEnabledController.getAddonEnabled(message); + case messages.TAB_SCROLL_TO: + return markController.scrollTo(message); + } + return undefined; + }); +}; diff --git a/src/content/store/index.ts b/src/content/store/index.ts deleted file mode 100644 index 5c41744..0000000 --- a/src/content/store/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -import promise from 'redux-promise'; -import reducers from '../reducers'; -import { createStore, applyMiddleware } from 'redux'; - -export const newStore = () => createStore( - reducers, - applyMiddleware(promise), -); diff --git a/src/content/urls.ts b/src/content/urls.ts deleted file mode 100644 index 035b9bb..0000000 --- a/src/content/urls.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as messages from '../shared/messages'; -import * as urls from '../shared/urls'; -import { Search } from '../shared/Settings'; - -const yank = (win: Window) => { - let input = win.document.createElement('input'); - win.document.body.append(input); - - input.style.position = 'fixed'; - input.style.top = '-100px'; - input.value = win.location.href; - input.select(); - - win.document.execCommand('copy'); - - input.remove(); -}; - -const paste = (win: Window, newTab: boolean, search: Search) => { - let textarea = win.document.createElement('textarea'); - win.document.body.append(textarea); - - textarea.style.position = 'fixed'; - textarea.style.top = '-100px'; - textarea.contentEditable = 'true'; - textarea.focus(); - - if (win.document.execCommand('paste')) { - let value = textarea.textContent as string; - let url = urls.searchUrl(value, search); - browser.runtime.sendMessage({ - type: messages.OPEN_URL, - url, - newTab, - }); - } - - textarea.remove(); -}; - -export { yank, paste }; diff --git a/src/content/usecases/AddonEnabledUseCase.ts b/src/content/usecases/AddonEnabledUseCase.ts new file mode 100644 index 0000000..e9ce0a6 --- /dev/null +++ b/src/content/usecases/AddonEnabledUseCase.ts @@ -0,0 +1,40 @@ +import AddonIndicatorClient, { AddonIndicatorClientImpl } + from '../client/AddonIndicatorClient'; +import AddonEnabledRepository, { AddonEnabledRepositoryImpl } + from '../repositories/AddonEnabledRepository'; + +export default class AddonEnabledUseCase { + private indicator: AddonIndicatorClient; + + private repository: AddonEnabledRepository; + + constructor({ + indicator = new AddonIndicatorClientImpl(), + repository = new AddonEnabledRepositoryImpl(), + } = {}) { + this.indicator = indicator; + this.repository = repository; + } + + async enable(): Promise<void> { + await this.setEnabled(true); + } + + async disable(): Promise<void> { + await this.setEnabled(false); + } + + async toggle(): Promise<void> { + let current = this.repository.get(); + await this.setEnabled(!current); + } + + getEnabled(): boolean { + return this.repository.get(); + } + + private async setEnabled(on: boolean): Promise<void> { + this.repository.set(on); + await this.indicator.setEnabled(on); + } +} diff --git a/src/content/usecases/ClipboardUseCase.ts b/src/content/usecases/ClipboardUseCase.ts new file mode 100644 index 0000000..b2ece2f --- /dev/null +++ b/src/content/usecases/ClipboardUseCase.ts @@ -0,0 +1,44 @@ +import * as urls from '../../shared/urls'; +import ClipboardRepository, { ClipboardRepositoryImpl } + from '../repositories/ClipboardRepository'; +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; +import TabsClient, { TabsClientImpl } + from '../client/TabsClient'; +import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient'; + +export default class ClipboardUseCase { + private repository: ClipboardRepository; + + private settingRepository: SettingRepository; + + private client: TabsClient; + + private consoleClient: ConsoleClient; + + constructor({ + repository = new ClipboardRepositoryImpl(), + settingRepository = new SettingRepositoryImpl(), + client = new TabsClientImpl(), + consoleClient = new ConsoleClientImpl(), + } = {}) { + this.repository = repository; + this.settingRepository = settingRepository; + this.client = client; + this.consoleClient = consoleClient; + } + + async yankCurrentURL(): Promise<string> { + let url = window.location.href; + this.repository.write(url); + await this.consoleClient.info('Yanked ' + url); + return Promise.resolve(url); + } + + async openOrSearch(newTab: boolean): Promise<void> { + let search = this.settingRepository.get().search; + let text = this.repository.read(); + let url = urls.searchUrl(text, search); + await this.client.openUrl(url, newTab); + } +} diff --git a/src/content/usecases/ConsoleFrameUseCase.ts b/src/content/usecases/ConsoleFrameUseCase.ts new file mode 100644 index 0000000..b4c756c --- /dev/null +++ b/src/content/usecases/ConsoleFrameUseCase.ts @@ -0,0 +1,17 @@ +import ConsoleFramePresenter, { ConsoleFramePresenterImpl } + from '../presenters/ConsoleFramePresenter'; + +export default class ConsoleFrameUseCase { + private consoleFramePresenter: ConsoleFramePresenter; + + constructor({ + consoleFramePresenter = new ConsoleFramePresenterImpl(), + } = {}) { + this.consoleFramePresenter = consoleFramePresenter; + } + + unfocus() { + window.focus(); + this.consoleFramePresenter.blur(); + } +} 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/FindUseCase.ts b/src/content/usecases/FindUseCase.ts new file mode 100644 index 0000000..74cbc97 --- /dev/null +++ b/src/content/usecases/FindUseCase.ts @@ -0,0 +1,81 @@ +import FindPresenter, { FindPresenterImpl } from '../presenters/FindPresenter'; +import FindRepository, { FindRepositoryImpl } + from '../repositories/FindRepository'; +import FindClient, { FindClientImpl } from '../client/FindClient'; +import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient'; + +export default class FindUseCase { + private presenter: FindPresenter; + + private repository: FindRepository; + + private client: FindClient; + + private consoleClient: ConsoleClient; + + constructor({ + presenter = new FindPresenterImpl() as FindPresenter, + repository = new FindRepositoryImpl(), + client = new FindClientImpl(), + consoleClient = new ConsoleClientImpl(), + } = {}) { + this.presenter = presenter; + this.repository = repository; + this.client = client; + this.consoleClient = consoleClient; + } + + async startFind(keyword?: string): Promise<void> { + this.presenter.clearSelection(); + if (keyword) { + this.saveKeyword(keyword); + } else { + let lastKeyword = await this.getKeyword(); + if (!lastKeyword) { + return this.showNoLastKeywordError(); + } + this.saveKeyword(lastKeyword); + } + return this.findNext(); + } + + findNext(): Promise<void> { + return this.findNextPrev(false); + } + + findPrev(): Promise<void> { + return this.findNextPrev(true); + } + + private async findNextPrev( + backwards: boolean, + ): Promise<void> { + let keyword = await this.getKeyword(); + if (!keyword) { + return this.showNoLastKeywordError(); + } + let found = this.presenter.find(keyword, backwards); + if (found) { + this.consoleClient.info('Pattern found: ' + keyword); + } else { + this.consoleClient.error('Pattern not found: ' + keyword); + } + } + + private async getKeyword(): Promise<string | null> { + let keyword = this.repository.getLastKeyword(); + if (!keyword) { + keyword = await this.client.getGlobalLastKeyword(); + } + return keyword; + } + + private async saveKeyword(keyword: string): Promise<void> { + this.repository.setLastKeyword(keyword); + await this.client.setGlobalLastKeyword(keyword); + } + + private async showNoLastKeywordError(): Promise<void> { + await this.consoleClient.error('No previous search keywords'); + } +} diff --git a/src/content/usecases/FocusUseCase.ts b/src/content/usecases/FocusUseCase.ts new file mode 100644 index 0000000..0ad4021 --- /dev/null +++ b/src/content/usecases/FocusUseCase.ts @@ -0,0 +1,16 @@ +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/FollowMasterUseCase.ts b/src/content/usecases/FollowMasterUseCase.ts new file mode 100644 index 0000000..9cbb790 --- /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 './HintKeyProducer'; +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/src/content/usecases/HintKeyProducer.ts b/src/content/usecases/HintKeyProducer.ts new file mode 100644 index 0000000..241cd56 --- /dev/null +++ b/src/content/usecases/HintKeyProducer.ts @@ -0,0 +1,38 @@ +export default class HintKeyProducer { + private charset: string; + + private counter: number[]; + + constructor(charset: string) { + if (charset.length === 0) { + throw new TypeError('charset is empty'); + } + + this.charset = charset; + this.counter = []; + } + + produce(): string { + this.increment(); + + return this.counter.map(x => this.charset[x]).join(''); + } + + private increment(): void { + let max = this.charset.length - 1; + if (this.counter.every(x => x === max)) { + this.counter = new Array(this.counter.length + 1).fill(0); + return; + } + + this.counter.reverse(); + let len = this.charset.length; + let num = this.counter.reduce((x, y, index) => x + y * len ** index) + 1; + for (let i = 0; i < this.counter.length; ++i) { + this.counter[i] = num % len; + num = ~~(num / len); + } + this.counter.reverse(); + } +} + diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts new file mode 100644 index 0000000..af0ad77 --- /dev/null +++ b/src/content/usecases/KeymapUseCase.ts @@ -0,0 +1,87 @@ +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 Key from '../domains/Key'; +import KeySequence, * as keySequenceUtils from '../domains/KeySequence'; + +type KeymapEntityMap = Map<KeySequence, operations.Operation>; + +const reservedKeymaps: Keymaps = { + '<Esc>': { type: operations.CANCEL }, + '<C-[>': { type: operations.CANCEL }, +}; + + +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: Key): operations.Operation | null { + let sequence = this.repository.enqueueKey(key); + + let keymaps = this.keymapEntityMap(); + let matched = Array.from(keymaps.keys()).filter( + (mapping: KeySequence) => { + return mapping.startsWith(sequence); + }); + 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 && sequence.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 [ + keySequenceUtils.fromMapKeys(entry[0]), + entry[1], + ]; + }) as [KeySequence, operations.Operation][]; + return new Map<KeySequence, operations.Operation>(entries); + } +} diff --git a/src/content/usecases/MarkKeyUseCase.ts b/src/content/usecases/MarkKeyUseCase.ts new file mode 100644 index 0000000..c0aa655 --- /dev/null +++ b/src/content/usecases/MarkKeyUseCase.ts @@ -0,0 +1,36 @@ +import MarkKeyRepository, { MarkKeyRepositoryImpl } + from '../repositories/MarkKeyRepository'; + +export default class MarkKeyUseCase { + private repository: MarkKeyRepository; + + constructor({ + repository = new MarkKeyRepositoryImpl() + } = {}) { + this.repository = repository; + } + + isSetMode(): boolean { + return this.repository.isSetMode(); + } + + isJumpMode(): boolean { + return this.repository.isJumpMode(); + } + + enableSetMode(): void { + this.repository.enableSetMode(); + } + + disableSetMode(): void { + this.repository.disabeSetMode(); + } + + enableJumpMode(): void { + this.repository.enableJumpMode(); + } + + disableJumpMode(): void { + this.repository.disabeJumpMode(); + } +} diff --git a/src/content/usecases/MarkUseCase.ts b/src/content/usecases/MarkUseCase.ts new file mode 100644 index 0000000..530f141 --- /dev/null +++ b/src/content/usecases/MarkUseCase.ts @@ -0,0 +1,66 @@ +import ScrollPresenter, { ScrollPresenterImpl } + from '../presenters/ScrollPresenter'; +import MarkClient, { MarkClientImpl } from '../client/MarkClient'; +import MarkRepository, { MarkRepositoryImpl } + from '../repositories/MarkRepository'; +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; +import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient'; + +export default class MarkUseCase { + private scrollPresenter: ScrollPresenter; + + private client: MarkClient; + + private repository: MarkRepository; + + private settingRepository: SettingRepository; + + private consoleClient: ConsoleClient; + + constructor({ + scrollPresenter = new ScrollPresenterImpl(), + client = new MarkClientImpl(), + repository = new MarkRepositoryImpl(), + settingRepository = new SettingRepositoryImpl(), + consoleClient = new ConsoleClientImpl(), + } = {}) { + this.scrollPresenter = scrollPresenter; + this.client = client; + this.repository = repository; + this.settingRepository = settingRepository; + this.consoleClient = consoleClient; + } + + async set(key: string): Promise<void> { + let pos = this.scrollPresenter.getScroll(); + if (this.globalKey(key)) { + this.client.setGloablMark(key, pos); + await this.consoleClient.info(`Set global mark to '${key}'`); + } else { + this.repository.set(key, pos); + await this.consoleClient.info(`Set local mark to '${key}'`); + } + } + + async jump(key: string): Promise<void> { + if (this.globalKey(key)) { + await this.client.jumpGlobalMark(key); + } else { + let pos = this.repository.get(key); + if (!pos) { + throw new Error('Mark is not set'); + } + this.scroll(pos.x, pos.y); + } + } + + scroll(x: number, y: number): void { + let smooth = this.settingRepository.get().properties.smoothscroll; + this.scrollPresenter.scrollTo(x, y, smooth); + } + + private globalKey(key: string) { + return (/^[A-Z0-9]$/).test(key); + } +} diff --git a/src/content/usecases/NavigateUseCase.ts b/src/content/usecases/NavigateUseCase.ts new file mode 100644 index 0000000..6f82d3f --- /dev/null +++ b/src/content/usecases/NavigateUseCase.ts @@ -0,0 +1,36 @@ +import NavigationPresenter, { NavigationPresenterImpl } + from '../presenters/NavigationPresenter'; + +export default class NavigateUseCase { + private navigationPresenter: NavigationPresenter; + + constructor({ + navigationPresenter = new NavigationPresenterImpl(), + } = {}) { + this.navigationPresenter = navigationPresenter; + } + + openHistoryPrev(): void { + this.navigationPresenter.openHistoryPrev(); + } + + openHistoryNext(): void { + this.navigationPresenter.openHistoryNext(); + } + + openLinkPrev(): void { + this.navigationPresenter.openLinkPrev(); + } + + openLinkNext(): void { + this.navigationPresenter.openLinkNext(); + } + + openParent(): void { + this.navigationPresenter.openParent(); + } + + openRoot(): void { + this.navigationPresenter.openRoot(); + } +} 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; + } +} diff --git a/src/content/usecases/SettingUseCase.ts b/src/content/usecases/SettingUseCase.ts new file mode 100644 index 0000000..765cb45 --- /dev/null +++ b/src/content/usecases/SettingUseCase.ts @@ -0,0 +1,24 @@ +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; +import SettingClient, { SettingClientImpl } from '../client/SettingClient'; +import Settings from '../../shared/Settings'; + +export default class SettingUseCase { + private repository: SettingRepository; + + private client: SettingClient; + + constructor({ + repository = new SettingRepositoryImpl(), + client = new SettingClientImpl(), + } = {}) { + this.repository = repository; + this.client = client; + } + + async reload(): Promise<Settings> { + let settings = await this.client.load(); + this.repository.set(settings); + return settings; + } +} diff --git a/src/shared/messages.ts b/src/shared/messages.ts index 41b0f0b..fbd3478 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -42,162 +42,164 @@ export const SETTINGS_QUERY = 'settings.query'; export const CONSOLE_FRAME_MESSAGE = 'console.frame.message'; -interface BackgroundOperationMessage { +export interface BackgroundOperationMessage { type: typeof BACKGROUND_OPERATION; operation: operations.Operation; } -interface ConsoleUnfocusMessage { +export interface ConsoleUnfocusMessage { type: typeof CONSOLE_UNFOCUS; } -interface ConsoleEnterCommandMessage { +export interface ConsoleEnterCommandMessage { type: typeof CONSOLE_ENTER_COMMAND; text: string; } -interface ConsoleEnterFindMessage { +export interface ConsoleEnterFindMessage { type: typeof CONSOLE_ENTER_FIND; - text: string; + text?: string; } -interface ConsoleQueryCompletionsMessage { +export interface ConsoleQueryCompletionsMessage { type: typeof CONSOLE_QUERY_COMPLETIONS; text: string; } -interface ConsoleShowCommandMessage { +export interface ConsoleShowCommandMessage { type: typeof CONSOLE_SHOW_COMMAND; command: string; } -interface ConsoleShowErrorMessage { +export interface ConsoleShowErrorMessage { type: typeof CONSOLE_SHOW_ERROR; text: string; } -interface ConsoleShowInfoMessage { +export interface ConsoleShowInfoMessage { type: typeof CONSOLE_SHOW_INFO; text: string; } -interface ConsoleShowFindMessage { +export interface ConsoleShowFindMessage { type: typeof CONSOLE_SHOW_FIND; } -interface ConsoleHideMessage { +export interface ConsoleHideMessage { type: typeof CONSOLE_HIDE; } -interface FollowStartMessage { +export interface FollowStartMessage { type: typeof FOLLOW_START; newTab: boolean; background: boolean; } -interface FollowRequestCountTargetsMessage { +export interface FollowRequestCountTargetsMessage { type: typeof FOLLOW_REQUEST_COUNT_TARGETS; viewSize: { width: number, height: number }; framePosition: { x: number, y: number }; } -interface FollowResponseCountTargetsMessage { +export interface FollowResponseCountTargetsMessage { type: typeof FOLLOW_RESPONSE_COUNT_TARGETS; count: number; } -interface FollowCreateHintsMessage { +export interface FollowCreateHintsMessage { type: typeof FOLLOW_CREATE_HINTS; - keysArray: string[]; - newTab: boolean; - background: boolean; + tags: string[]; + viewSize: { width: number, height: number }; + framePosition: { x: number, y: number }; } -interface FollowShowHintsMessage { +export interface FollowShowHintsMessage { type: typeof FOLLOW_SHOW_HINTS; - keys: string; + prefix: string; } -interface FollowRemoveHintsMessage { +export interface FollowRemoveHintsMessage { type: typeof FOLLOW_REMOVE_HINTS; } -interface FollowActivateMessage { +export interface FollowActivateMessage { type: typeof FOLLOW_ACTIVATE; - keys: string; + tag: string; + newTab: boolean; + background: boolean; } -interface FollowKeyPressMessage { +export interface FollowKeyPressMessage { type: typeof FOLLOW_KEY_PRESS; key: string; ctrlKey: boolean; } -interface MarkSetGlobalMessage { +export interface MarkSetGlobalMessage { type: typeof MARK_SET_GLOBAL; key: string; x: number; y: number; } -interface MarkJumpGlobalMessage { +export interface MarkJumpGlobalMessage { type: typeof MARK_JUMP_GLOBAL; key: string; } -interface TabScrollToMessage { +export interface TabScrollToMessage { type: typeof TAB_SCROLL_TO; x: number; y: number; } -interface FindNextMessage { +export interface FindNextMessage { type: typeof FIND_NEXT; } -interface FindPrevMessage { +export interface FindPrevMessage { type: typeof FIND_PREV; } -interface FindGetKeywordMessage { +export interface FindGetKeywordMessage { type: typeof FIND_GET_KEYWORD; } -interface FindSetKeywordMessage { +export interface FindSetKeywordMessage { type: typeof FIND_SET_KEYWORD; keyword: string; found: boolean; } -interface AddonEnabledQueryMessage { +export interface AddonEnabledQueryMessage { type: typeof ADDON_ENABLED_QUERY; } -interface AddonEnabledResponseMessage { +export interface AddonEnabledResponseMessage { type: typeof ADDON_ENABLED_RESPONSE; enabled: boolean; } -interface AddonToggleEnabledMessage { +export interface AddonToggleEnabledMessage { type: typeof ADDON_TOGGLE_ENABLED; } -interface OpenUrlMessage { +export interface OpenUrlMessage { type: typeof OPEN_URL; url: string; newTab: boolean; background: boolean; } -interface SettingsChangedMessage { +export interface SettingsChangedMessage { type: typeof SETTINGS_CHANGED; } -interface SettingsQueryMessage { +export interface SettingsQueryMessage { type: typeof SETTINGS_QUERY; } -interface ConsoleFrameMessageMessage { +export interface ConsoleFrameMessageMessage { type: typeof CONSOLE_FRAME_MESSAGE; message: any; } diff --git a/src/shared/urls.ts b/src/shared/urls.ts index 18349c8..bbdb1ea 100644 --- a/src/shared/urls.ts +++ b/src/shared/urls.ts @@ -1,3 +1,5 @@ +import { Search } from './Settings'; + const trimStart = (str: string): string => { // NOTE String.trimStart is available on Firefox 61 return str.replace(/^\s+/, ''); @@ -5,7 +7,7 @@ const trimStart = (str: string): string => { const SUPPORTED_PROTOCOLS = ['http:', 'https:', 'ftp:', 'mailto:', 'about:']; -const searchUrl = (keywords: string, searchSettings: any): string => { +const searchUrl = (keywords: string, search: Search): string => { try { let u = new URL(keywords); if (SUPPORTED_PROTOCOLS.includes(u.protocol.toLowerCase())) { @@ -17,12 +19,12 @@ const searchUrl = (keywords: string, searchSettings: any): string => { if (keywords.includes('.') && !keywords.includes(' ')) { return 'http://' + keywords; } - let template = searchSettings.engines[searchSettings.default]; + let template = search.engines[search.default]; let query = keywords; let first = trimStart(keywords).split(' ')[0]; - if (Object.keys(searchSettings.engines).includes(first)) { - template = searchSettings.engines[first]; + if (Object.keys(search.engines).includes(first)) { + template = search.engines[first]; query = trimStart(trimStart(keywords).slice(first.length)); } return template.replace('{}', encodeURIComponent(query)); diff --git a/test/content/InputDriver.test.ts b/test/content/InputDriver.test.ts new file mode 100644 index 0000000..b9f2c28 --- /dev/null +++ b/test/content/InputDriver.test.ts @@ -0,0 +1,129 @@ +import InputDriver from '../../src/content/InputDriver'; +import { expect } from 'chai'; +import Key from '../../src/content/domains/Key'; + +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' })); + }); +}); diff --git a/test/content/actions/follow-controller.test.ts b/test/content/actions/follow-controller.test.ts deleted file mode 100644 index a4b1710..0000000 --- a/test/content/actions/follow-controller.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import * as actions from 'content/actions'; -import * as followControllerActions from 'content/actions/follow-controller'; - -describe('follow-controller actions', () => { - describe('enable', () => { - it('creates FOLLOW_CONTROLLER_ENABLE action', () => { - let action = followControllerActions.enable(true); - expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_ENABLE); - expect(action.newTab).to.equal(true); - }); - }); - - describe('disable', () => { - it('creates FOLLOW_CONTROLLER_DISABLE action', () => { - let action = followControllerActions.disable(true); - expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_DISABLE); - }); - }); - - describe('keyPress', () => { - it('creates FOLLOW_CONTROLLER_KEY_PRESS action', () => { - let action = followControllerActions.keyPress(100); - expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_KEY_PRESS); - expect(action.key).to.equal(100); - }); - }); - - describe('backspace', () => { - it('creates FOLLOW_CONTROLLER_BACKSPACE action', () => { - let action = followControllerActions.backspace(100); - expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_BACKSPACE); - }); - }); -}); diff --git a/test/content/actions/input.test.ts b/test/content/actions/input.test.ts deleted file mode 100644 index 33238a5..0000000 --- a/test/content/actions/input.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import * as actions from 'content/actions'; -import * as inputActions from 'content/actions/input'; - -describe("input actions", () => { - describe("keyPress", () => { - it('create INPUT_KEY_PRESS action', () => { - let action = inputActions.keyPress('a'); - expect(action.type).to.equal(actions.INPUT_KEY_PRESS); - expect(action.key).to.equal('a'); - }); - }); - - describe("clearKeys", () => { - it('create INPUT_CLEAR_KEYSaction', () => { - let action = inputActions.clearKeys(); - expect(action.type).to.equal(actions.INPUT_CLEAR_KEYS); - }); - }); -}); diff --git a/test/content/actions/mark.test.ts b/test/content/actions/mark.test.ts deleted file mode 100644 index 6c6d59e..0000000 --- a/test/content/actions/mark.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as actions from 'content/actions'; -import * as markActions from 'content/actions/mark'; - -describe('mark actions', () => { - describe('startSet', () => { - it('create MARK_START_SET action', () => { - let action = markActions.startSet(); - expect(action.type).to.equal(actions.MARK_START_SET); - }); - }); - - describe('startJump', () => { - it('create MARK_START_JUMP action', () => { - let action = markActions.startJump(); - expect(action.type).to.equal(actions.MARK_START_JUMP); - }); - }); - - describe('cancel', () => { - it('create MARK_CANCEL action', () => { - let action = markActions.cancel(); - expect(action.type).to.equal(actions.MARK_CANCEL); - }); - }); - - describe('setLocal', () => { - it('create setLocal action', () => { - let action = markActions.setLocal('a', 20, 30); - expect(action.type).to.equal(actions.MARK_SET_LOCAL); - expect(action.key).to.equal('a'); - expect(action.x).to.equal(20); - expect(action.y).to.equal(30); - }); - }); -}); diff --git a/test/content/actions/setting.test.ts b/test/content/actions/setting.test.ts deleted file mode 100644 index c831433..0000000 --- a/test/content/actions/setting.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import * as actions from 'content/actions'; -import * as settingActions from 'content/actions/setting'; - -describe("setting actions", () => { - describe("set", () => { - it('create SETTING_SET action', () => { - let action = settingActions.set({ - keymaps: { - 'dd': 'remove current tab', - 'z<C-A>': 'increment', - }, - search: { - default: "google", - engines: { - google: 'https://google.com/search?q={}', - } - }, - properties: { - hintchars: 'abcd1234', - }, - blacklist: [], - }); - expect(action.type).to.equal(actions.SETTING_SET); - expect(action.settings.properties.hintchars).to.equal('abcd1234'); - }); - - it('overrides cancel keys', () => { - let action = settingActions.set({ - keymaps: { - "k": { "type": "scroll.vertically", "count": -1 }, - "j": { "type": "scroll.vertically", "count": 1 }, - } - }); - let keymaps = action.settings.keymaps; - expect(action.settings.keymaps).to.deep.equals({ - "k": { type: "scroll.vertically", count: -1 }, - "j": { type: "scroll.vertically", count: 1 }, - '<Esc>': { type: 'cancel' }, - '<C-[>': { type: 'cancel' }, - }); - }); - }); -}); diff --git a/test/content/components/common/follow.html b/test/content/components/common/follow.html deleted file mode 100644 index b2a2d74..0000000 --- a/test/content/components/common/follow.html +++ /dev/null @@ -1,17 +0,0 @@ -<!DOCTYPE html> -<html> - <body> - <a id='visible_a' href='#' >link</a> - <a href='#' style='display:none'>invisible 1</a> - <a href='#' style='visibility:hidden'>invisible 2</a> - <i>not link<i> - <div id='editable_div_1' contenteditable>link</div> - <div id='editable_div_2' contenteditable='true'>link</div> - <div id='x' contenteditable='false'>link</div> - <details> - <summary id='summary_1'>summary link</summary> - Some details - <a href='#'>not visible</a> - </details> - </body> -</html> diff --git a/test/content/components/common/follow.test.ts b/test/content/components/common/follow.test.ts deleted file mode 100644 index 90d6cf5..0000000 --- a/test/content/components/common/follow.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import FollowComponent from 'content/components/common/follow'; - -describe('FollowComponent', () => { - describe('#getTargetElements', () => { - beforeEach(() => { - document.body.innerHTML = __html__['test/content/components/common/follow.html']; - }); - - it('returns visible links', () => { - let targets = FollowComponent.getTargetElements( - window, - { width: window.innerWidth, height: window.innerHeight }, - { x: 0, y: 0 }); - expect(targets).to.have.lengthOf(4); - - let ids = Array.prototype.map.call(targets, (e) => e.id); - expect(ids).to.include.members([ - 'visible_a', - 'editable_div_1', - 'editable_div_2', - 'summary_1', - ]); - }); - }); -}); diff --git a/test/content/components/common/hint.test.ts b/test/content/components/common/hint.test.ts deleted file mode 100644 index 42d571f..0000000 --- a/test/content/components/common/hint.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import Hint from 'content/components/common/hint'; - -describe('Hint class', () => { - beforeEach(() => { - document.body.innerHTML = __html__['test/content/components/common/hint.html']; - }); - - describe('#constructor', () => { - it('creates a hint element with tag name', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - expect(hint.element.textContent.trim()).to.be.equal('abc'); - }); - - it('throws an exception when non-element given', () => { - expect(() => new Hint(window, 'abc')).to.throw(TypeError); - }); - }); - - describe('#show', () => { - it('shows an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - hint.hide(); - hint.show(); - - expect(hint.element.style.display).to.not.equal('none'); - }); - }); - - describe('#hide', () => { - it('hides an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - hint.hide(); - - expect(hint.element.style.display).to.equal('none'); - }); - }); - - describe('#remove', () => { - it('removes an element', () => { - let link = document.getElementById('test-link'); - let hint = new Hint(link, 'abc'); - - expect(hint.element.parentElement).to.not.be.null; - hint.remove(); - expect(hint.element.parentElement).to.be.null; - }); - }); - - describe('#activate', () => { - // TODO test activations - }); -}); - - diff --git a/test/content/components/common/input.test.ts b/test/content/components/common/input.test.ts deleted file mode 100644 index f3a943c..0000000 --- a/test/content/components/common/input.test.ts +++ /dev/null @@ -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 }); - }) -}); diff --git a/test/shared/utils/keys.test.ts b/test/content/domains/Key.test.ts index b2ad3cb..b3f9fb6 100644 --- a/test/shared/utils/keys.test.ts +++ b/test/content/domains/Key.test.ts @@ -1,11 +1,12 @@ -import * as keys from 'shared/utils/keys'; +import Key, * as keys from '../../../src/content/domains/Key'; +import { expect } from 'chai' -describe("keys util", () => { +describe("Key", () => { describe('fromKeyboardEvent', () => { it('returns from keyboard input Ctrl+X', () => { - let k = keys.fromKeyboardEvent({ - key: 'x', shiftKey: false, ctrlKey: true, altKey: false, metaKey: true - }); + let k = keys.fromKeyboardEvent(new KeyboardEvent('keydown', { + key: 'x', shiftKey: false, ctrlKey: true, altKey: false, metaKey: true, + })); expect(k.key).to.equal('x'); expect(k.shiftKey).to.be.false; expect(k.ctrlKey).to.be.true; @@ -14,9 +15,9 @@ describe("keys util", () => { }); it('returns from keyboard input Shift+Esc', () => { - let k = keys.fromKeyboardEvent({ + let k = keys.fromKeyboardEvent(new KeyboardEvent('keydown', { key: 'Escape', shiftKey: true, ctrlKey: false, altKey: false, metaKey: true - }); + })); expect(k.key).to.equal('Esc'); expect(k.shiftKey).to.be.true; expect(k.ctrlKey).to.be.false; @@ -26,9 +27,9 @@ describe("keys util", () => { it('returns from keyboard input Ctrl+$', () => { // $ required shift pressing on most keyboards - let k = keys.fromKeyboardEvent({ + let k = keys.fromKeyboardEvent(new KeyboardEvent('keydown', { key: '$', shiftKey: true, ctrlKey: true, altKey: false, metaKey: false - }); + })); expect(k.key).to.equal('$'); expect(k.shiftKey).to.be.false; expect(k.ctrlKey).to.be.true; @@ -37,9 +38,9 @@ describe("keys util", () => { }); it('returns from keyboard input Crtl+Space', () => { - let k = keys.fromKeyboardEvent({ + let k = keys.fromKeyboardEvent(new KeyboardEvent('keydown', { key: ' ', shiftKey: false, ctrlKey: true, altKey: false, metaKey: false - }); + })); expect(k.key).to.equal('Space'); expect(k.shiftKey).to.be.false; expect(k.ctrlKey).to.be.true; @@ -122,43 +123,15 @@ describe("keys util", () => { }); }); - describe('fromMapKeys', () => { - it('returns mapped keys for Shift+Esc', () => { - let keyArray = keys.fromMapKeys('<S-Esc>'); - expect(keyArray).to.have.lengthOf(1); - expect(keyArray[0].key).to.equal('Esc'); - expect(keyArray[0].shiftKey).to.be.true; - }); - - it('returns mapped keys for a<C-B><A-C>d<M-e>', () => { - let keyArray = keys.fromMapKeys('a<C-B><A-C>d<M-e>'); - expect(keyArray).to.have.lengthOf(5); - expect(keyArray[0].key).to.equal('a'); - expect(keyArray[1].ctrlKey).to.be.true; - expect(keyArray[1].key).to.equal('b'); - expect(keyArray[2].altKey).to.be.true; - expect(keyArray[2].key).to.equal('c'); - expect(keyArray[3].key).to.equal('d'); - expect(keyArray[4].metaKey).to.be.true; - expect(keyArray[4].key).to.equal('e'); - }); - }) - describe('equals', () => { - expect(keys.equals({ - key: 'x', - ctrlKey: true, - }, { - key: 'x', - ctrlKey: true, - })).to.be.true; - - expect(keys.equals({ - key: 'X', - shiftKey: true, - }, { - key: 'x', - ctrlKey: true, - })).to.be.false; + expect(keys.equals( + { key: 'x', ctrlKey: true, }, + { key: 'x', ctrlKey: true, }, + )).to.be.true; + + expect(keys.equals( + { key: 'X', shiftKey: true, }, + { key: 'x', ctrlKey: true, }, + )).to.be.false; }); }); diff --git a/test/content/domains/KeySequence.test.ts b/test/content/domains/KeySequence.test.ts new file mode 100644 index 0000000..7387c06 --- /dev/null +++ b/test/content/domains/KeySequence.test.ts @@ -0,0 +1,72 @@ +import KeySequence, * as utils from '../../../src/content/domains/KeySequence'; +import { expect } from 'chai' + +describe("KeySequence", () => { + describe('#push', () => { + it('append a key to the sequence', () => { + let seq = KeySequence.from([]); + seq.push({ key: 'g' }); + seq.push({ key: 'u', shiftKey: true }); + + let array = seq.getKeyArray(); + expect(array[0]).to.deep.equal({ key: 'g' }); + expect(array[1]).to.deep.equal({ key: 'u', shiftKey: true }); + }) + }); + + describe('#startsWith', () => { + it('returns true if the key sequence starts with param', () => { + let seq = KeySequence.from([ + { key: 'g' }, + { key: 'u', shiftKey: true }, + ]); + + expect(seq.startsWith(KeySequence.from([ + ]))).to.be.true; + expect(seq.startsWith(KeySequence.from([ + { key: 'g' }, + ]))).to.be.true; + expect(seq.startsWith(KeySequence.from([ + { key: 'g' }, { key: 'u', shiftKey: true }, + ]))).to.be.true; + expect(seq.startsWith(KeySequence.from([ + { key: 'g' }, { key: 'u', shiftKey: true }, { key: 'x' }, + ]))).to.be.false; + expect(seq.startsWith(KeySequence.from([ + { key: 'h' }, + ]))).to.be.false; + }) + + it('returns true if the empty sequence starts with an empty sequence', () => { + let seq = KeySequence.from([]); + + expect(seq.startsWith(KeySequence.from([]))).to.be.true; + expect(seq.startsWith(KeySequence.from([ + { key: 'h' }, + ]))).to.be.false; + }) + }); + + describe('#fromMapKeys', () => { + it('returns mapped keys for Shift+Esc', () => { + let keyArray = utils.fromMapKeys('<S-Esc>').getKeyArray(); + expect(keyArray).to.have.lengthOf(1); + expect(keyArray[0].key).to.equal('Esc'); + expect(keyArray[0].shiftKey).to.be.true; + }); + + it('returns mapped keys for a<C-B><A-C>d<M-e>', () => { + let keyArray = utils.fromMapKeys('a<C-B><A-C>d<M-e>').getKeyArray(); + expect(keyArray).to.have.lengthOf(5); + expect(keyArray[0].key).to.equal('a'); + expect(keyArray[1].ctrlKey).to.be.true; + expect(keyArray[1].key).to.equal('b'); + expect(keyArray[2].altKey).to.be.true; + expect(keyArray[2].key).to.equal('c'); + expect(keyArray[3].key).to.equal('d'); + expect(keyArray[4].metaKey).to.be.true; + expect(keyArray[4].key).to.equal('e'); + }); + }) + +}); diff --git a/test/content/mock/MockConsoleClient.ts b/test/content/mock/MockConsoleClient.ts new file mode 100644 index 0000000..8de2d83 --- /dev/null +++ b/test/content/mock/MockConsoleClient.ts @@ -0,0 +1,26 @@ +import ConsoleClient from '../../../src/content/client/ConsoleClient'; + +export default class MockConsoleClient implements ConsoleClient { + public isError: boolean; + + public text: string; + + constructor() { + this.isError = false; + this.text = ''; + } + + info(text: string): Promise<void> { + this.isError = false; + this.text = text; + return Promise.resolve(); + } + + error(text: string): Promise<void> { + this.isError = true; + this.text = text; + return Promise.resolve(); + } +} + + diff --git a/test/content/mock/MockScrollPresenter.ts b/test/content/mock/MockScrollPresenter.ts new file mode 100644 index 0000000..819569a --- /dev/null +++ b/test/content/mock/MockScrollPresenter.ts @@ -0,0 +1,47 @@ +import ScrollPresenter, { Point } from '../../../src/content/presenters/ScrollPresenter'; + +export default class MockScrollPresenter implements ScrollPresenter { + private pos: Point; + + constructor() { + this.pos = { x: 0, y: 0 }; + } + + getScroll(): Point { + return this.pos; + } + + scrollVertically(amount: number, _smooth: boolean): void { + this.pos.y += amount; + } + + scrollHorizonally(amount: number, _smooth: boolean): void { + this.pos.x += amount; + } + + scrollPages(amount: number, _smooth: boolean): void { + this.pos.x += amount; + } + + scrollTo(x: number, y: number, _smooth: boolean): void { + this.pos.x = x; + this.pos.y = y; + } + + scrollToTop(_smooth: boolean): void { + this.pos.y = 0; + } + + scrollToBottom(_smooth: boolean): void { + this.pos.y = Infinity; + } + + scrollToHome(_smooth: boolean): void { + this.pos.x = 0; + } + + scrollToEnd(_smooth: boolean): void { + this.pos.x = Infinity; + } +} + diff --git a/test/content/components/common/hint.html b/test/content/presenters/Hint.test.html index b50c5fe..b50c5fe 100644 --- a/test/content/components/common/hint.html +++ b/test/content/presenters/Hint.test.html diff --git a/test/content/presenters/Hint.test.ts b/test/content/presenters/Hint.test.ts new file mode 100644 index 0000000..7994788 --- /dev/null +++ b/test/content/presenters/Hint.test.ts @@ -0,0 +1,158 @@ +import AbstractHint, { LinkHint, InputHint } from '../../../src/content/presenters/Hint'; +import { expect } from 'chai'; + +class Hint extends AbstractHint { +} + +describe('Hint', () => { + beforeEach(() => { + document.body.innerHTML = `<a id='test-link' href='#'>link</a>`; + }); + + describe('#constructor', () => { + it('creates a hint element with tag name', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + + let elem = document.querySelector('.vimvixen-hint'); + expect(elem.textContent.trim()).to.be.equal('abc'); + }); + }); + + describe('#show', () => { + it('shows an element', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + hint.hide(); + hint.show(); + + let elem = document.querySelector('.vimvixen-hint') as HTMLElement; + expect(elem.style.display).to.not.equal('none'); + }); + }); + + describe('#hide', () => { + it('hides an element', () => { + let link = document.getElementById('test-link') as HTMLElement; + let hint = new Hint(link, 'abc'); + hint.hide(); + + let elem = document.querySelector('.vimvixen-hint') as HTMLElement; + expect(elem.style.display).to.equal('none'); + }); + }); + + describe('#remove', () => { + it('removes an element', () => { + let link = document.getElementById('test-link'); + let hint = new Hint(link, 'abc'); + + let elem = document.querySelector('.vimvixen-hint'); + expect(elem.parentElement).to.not.be.null; + hint.remove(); + expect(elem.parentElement).to.be.null; + }); + }); +}); + +describe('LinkHint', () => { + beforeEach(() => { + document.body.innerHTML = ` +<a id='test-link1' href='https://google.com/'>link</a> +<a id='test-link2' href='https://yahoo.com/' target='_blank'>link</a> +<a id='test-link3' href='#' target='_blank'>link</a> +`; + }); + + describe('#getLink()', () => { + it('returns value of "href" attribute', () => { + let link = document.getElementById('test-link1') as HTMLAnchorElement; + let hint = new LinkHint(link, 'abc'); + + expect(hint.getLink()).to.equal('https://google.com/'); + }); + }); + + describe('#getLinkTarget()', () => { + it('returns value of "target" attribute', () => { + let link = document.getElementById('test-link1') as HTMLAnchorElement; + let hint = new LinkHint(link, 'abc'); + + expect(hint.getLinkTarget()).to.be.null; + + link = document.getElementById('test-link2') as HTMLAnchorElement; + hint = new LinkHint(link, 'abc'); + + expect(hint.getLinkTarget()).to.equal('_blank'); + }); + }); + + describe('#click()', () => { + it('clicks a element', (done) => { + let link = document.getElementById('test-link3') as HTMLAnchorElement; + let hint = new LinkHint(link, 'abc'); + link.onclick = () => { done() }; + + hint.click(); + }); + }); +}); + +describe('InputHint', () => { + describe('#activate()', () => { + context('<input>', () => { + beforeEach(() => { + document.body.innerHTML = `<input id='test-input'></input>`; + }); + + it('focuses to the input', () => { + let input = document.getElementById('test-input') as HTMLInputElement; + let hint = new InputHint(input, 'abc'); + hint.activate(); + + expect(document.activeElement).to.equal(input); + }); + }); + + context('<input type="checkbox">', () => { + beforeEach(() => { + document.body.innerHTML = `<input type="checkbox" id='test-input'></input>`; + }); + + it('checks and focuses to the input', () => { + let input = document.getElementById('test-input') as HTMLInputElement; + let hint = new InputHint(input, 'abc'); + hint.activate(); + + expect(input.checked).to.be.true; + }); + }); + context('<textarea>', () => { + beforeEach(() => { + document.body.innerHTML = `<textarea id='test-textarea'></textarea>`; + }); + + it('focuses to the textarea', () => { + let textarea = document.getElementById('test-textarea') as HTMLTextAreaElement; + let hint = new InputHint(textarea, 'abc'); + hint.activate(); + + expect(document.activeElement).to.equal(textarea); + }); + }); + + context('<button>', () => { + beforeEach(() => { + document.body.innerHTML = `<button id='test-button'></button>`; + }); + + it('clicks the button', (done) => { + let button = document.getElementById('test-button') as HTMLButtonElement; + button.onclick = () => { done() }; + + let hint = new InputHint(button, 'abc'); + hint.activate(); + }); + }); + }); +}); diff --git a/test/content/navigates.test.ts b/test/content/presenters/NavigationPresenter.test.ts index 1d73344..c1aca9a 100644 --- a/test/content/navigates.test.ts +++ b/test/content/presenters/NavigationPresenter.test.ts @@ -1,19 +1,26 @@ -import * as navigates from 'content/navigates'; - -const testRel = (done, rel, html) => { - const method = rel === 'prev' ? 'linkPrev' : 'linkNext'; - document.body.innerHTML = html; - navigates[method](window); - setTimeout(() => { - expect(document.location.hash).to.equal(`#${rel}`); - done(); - }, 0); -}; - -const testPrev = html => done => testRel(done, 'prev', html); -const testNext = html => done => testRel(done, 'next', html); - -describe('navigates module', () => { +import NavigationPresenter, { NavigationPresenterImpl } + from '../../../src/content/presenters/NavigationPresenter'; +import { expect } from 'chai'; + +describe('NavigationPresenter', () => { + let sut; + + const testRel = (done, rel, html) => { + const method = rel === 'prev' ? sut.openLinkPrev.bind(sut) : sut.openLinkNext.bind(sut); + document.body.innerHTML = html; + method(); + setTimeout(() => { + expect(document.location.hash).to.equal(`#${rel}`); + done(); + }, 0); + }; + const testPrev = html => done => testRel(done, 'prev', html); + const testNext = html => done => testRel(done, 'next', html); + + before(() => { + sut = new NavigationPresenterImpl(); + }); + describe('#linkPrev', () => { it('navigates to <link> elements whose rel attribute is "prev"', testPrev( '<link rel="prev" href="#prev" />' @@ -130,7 +137,7 @@ describe('navigates module', () => { // NOTE: not able to test location it('removes hash', () => { window.location.hash = '#section-1'; - navigates.parent(window); + sut.openParent(); expect(document.location.hash).to.be.empty; }); }); diff --git a/test/content/reducers/addon.test.ts b/test/content/reducers/addon.test.ts deleted file mode 100644 index fb05244..0000000 --- a/test/content/reducers/addon.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as actions from 'content/actions'; -import addonReducer from 'content/reducers/addon'; - -describe("addon reducer", () => { - it('return the initial state', () => { - let state = addonReducer(undefined, {}); - expect(state).to.have.property('enabled', true); - }); - - it('return next state for ADDON_SET_ENABLED', () => { - let action = { type: actions.ADDON_SET_ENABLED, enabled: true }; - let prev = { enabled: false }; - let state = addonReducer(prev, action); - - expect(state.enabled).is.equal(true); - }); -}); diff --git a/test/content/reducers/find.test.ts b/test/content/reducers/find.test.ts deleted file mode 100644 index 66a2c67..0000000 --- a/test/content/reducers/find.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import * as actions from 'content/actions'; -import findReducer from 'content/reducers/find'; - -describe("find reducer", () => { - it('return the initial state', () => { - let state = findReducer(undefined, {}); - expect(state).to.have.property('keyword', null); - expect(state).to.have.property('found', false); - }); - - it('return next state for FIND_SET_KEYWORD', () => { - let action = { - type: actions.FIND_SET_KEYWORD, - keyword: 'xyz', - found: true, - }; - let state = findReducer({}, action); - - expect(state.keyword).is.equal('xyz'); - expect(state.found).to.be.true; - }); -}); diff --git a/test/content/reducers/follow-controller.test.ts b/test/content/reducers/follow-controller.test.ts deleted file mode 100644 index 39f326c..0000000 --- a/test/content/reducers/follow-controller.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as actions from 'content/actions'; -import followControllerReducer from 'content/reducers/follow-controller'; - -describe('follow-controller reducer', () => { - it ('returns the initial state', () => { - let state = followControllerReducer(undefined, {}); - expect(state).to.have.property('enabled', false); - expect(state).to.have.property('newTab'); - expect(state).to.have.deep.property('keys', ''); - }); - - it ('returns next state for FOLLOW_CONTROLLER_ENABLE', () => { - let action = { type: actions.FOLLOW_CONTROLLER_ENABLE, newTab: true }; - let state = followControllerReducer({ enabled: false, newTab: false }, action); - expect(state).to.have.property('enabled', true); - expect(state).to.have.property('newTab', true); - expect(state).to.have.property('keys', ''); - }); - - it ('returns next state for FOLLOW_CONTROLLER_DISABLE', () => { - let action = { type: actions.FOLLOW_CONTROLLER_DISABLE }; - let state = followControllerReducer({ enabled: true }, action); - expect(state).to.have.property('enabled', false); - }); - - it ('returns next state for FOLLOW_CONTROLLER_KEY_PRESS', () => { - let action = { type: actions.FOLLOW_CONTROLLER_KEY_PRESS, key: 'a'}; - let state = followControllerReducer({ keys: '' }, action); - expect(state).to.have.deep.property('keys', 'a'); - - action = { type: actions.FOLLOW_CONTROLLER_KEY_PRESS, key: 'b'}; - state = followControllerReducer(state, action); - expect(state).to.have.deep.property('keys', 'ab'); - }); - - it ('returns next state for FOLLOW_CONTROLLER_BACKSPACE', () => { - let action = { type: actions.FOLLOW_CONTROLLER_BACKSPACE }; - let state = followControllerReducer({ keys: 'ab' }, action); - expect(state).to.have.deep.property('keys', 'a'); - - state = followControllerReducer(state, action); - expect(state).to.have.deep.property('keys', ''); - - state = followControllerReducer(state, action); - expect(state).to.have.deep.property('keys', ''); - }); -}); diff --git a/test/content/reducers/input.test.ts b/test/content/reducers/input.test.ts deleted file mode 100644 index f892201..0000000 --- a/test/content/reducers/input.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as actions from 'content/actions'; -import inputReducer from 'content/reducers/input'; - -describe("input reducer", () => { - it('return the initial state', () => { - let state = inputReducer(undefined, {}); - expect(state).to.have.deep.property('keys', []); - }); - - it('return next state for INPUT_KEY_PRESS', () => { - let action = { type: actions.INPUT_KEY_PRESS, key: 'a' }; - let state = inputReducer(undefined, action); - expect(state).to.have.deep.property('keys', ['a']); - - action = { type: actions.INPUT_KEY_PRESS, key: 'b' }; - state = inputReducer(state, action); - expect(state).to.have.deep.property('keys', ['a', 'b']); - }); - - it('return next state for INPUT_CLEAR_KEYS', () => { - let action = { type: actions.INPUT_CLEAR_KEYS }; - let state = inputReducer({ keys: [1, 2, 3] }, action); - expect(state).to.have.deep.property('keys', []); - }); -}); diff --git a/test/content/reducers/mark.test.ts b/test/content/reducers/mark.test.ts deleted file mode 100644 index 1a51c3e..0000000 --- a/test/content/reducers/mark.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import * as actions from 'content/actions'; -import reducer from 'content/reducers/mark'; - -describe("mark reducer", () => { - it('return the initial state', () => { - let state = reducer(undefined, {}); - expect(state.setMode).to.be.false; - expect(state.jumpMode).to.be.false; - expect(state.marks).to.be.empty; - }); - - it('starts set mode', () => { - let action = { type: actions.MARK_START_SET }; - let state = reducer(undefined, action); - expect(state.setMode).to.be.true; - }); - - it('starts jump mode', () => { - let action = { type: actions.MARK_START_JUMP }; - let state = reducer(undefined, action); - expect(state.jumpMode).to.be.true; - }); - - it('cancels set and jump mode', () => { - let action = { type: actions.MARK_CANCEL }; - let state = reducer({ setMode: true }, action); - expect(state.setMode).to.be.false; - - state = reducer({ jumpMode: true }, action); - expect(state.jumpMode).to.be.false; - }); - - it('stores local mark', () => { - let action = { type: actions.MARK_SET_LOCAL, key: 'a', x: 20, y: 30}; - let state = reducer({ setMode: true }, action); - expect(state.setMode).to.be.false; - expect(state.marks['a']).to.be.an('object') - expect(state.marks['a'].x).to.equal(20) - expect(state.marks['a'].y).to.equal(30) - }); -}); diff --git a/test/content/reducers/setting.test.ts b/test/content/reducers/setting.test.ts deleted file mode 100644 index 9b332aa..0000000 --- a/test/content/reducers/setting.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import * as actions from 'content/actions'; -import settingReducer from 'content/reducers/setting'; - -describe("content setting reducer", () => { - it('return the initial state', () => { - let state = settingReducer(undefined, {}); - expect(state.keymaps).to.be.empty; - }); - - it('return next state for SETTING_SET', () => { - let newSettings = { red: 'apple', yellow: 'banana' }; - let action = { - type: actions.SETTING_SET, - settings: { - keymaps: { - "zz": { type: "zoom.neutral" }, - "<S-Esc>": { "type": "addon.toggle.enabled" } - }, - "blacklist": [] - } - } - let state = settingReducer(undefined, action); - expect(state.keymaps).to.have.deep.all.members([ - { key: [{ key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }, - { key: 'z', shiftKey: false, ctrlKey: false, altKey: false, metaKey: false }], - op: { type: 'zoom.neutral' }}, - { key: [{ key: 'Esc', shiftKey: true, ctrlKey: false, altKey: false, metaKey: false }], - op: { type: 'addon.toggle.enabled' }}, - ]); - }); -}); diff --git a/test/content/repositories/AddonEnabledRepository.test.ts b/test/content/repositories/AddonEnabledRepository.test.ts new file mode 100644 index 0000000..3cea897 --- /dev/null +++ b/test/content/repositories/AddonEnabledRepository.test.ts @@ -0,0 +1,15 @@ +import { AddonEnabledRepositoryImpl } from '../../../src/content/repositories/AddonEnabledRepository'; +import { expect } from 'chai'; + +describe('AddonEnabledRepositoryImpl', () => { + it('updates and gets current value', () => { + let sut = new AddonEnabledRepositoryImpl(); + + sut.set(true); + expect(sut.get()).to.be.true; + + sut.set(false); + expect(sut.get()).to.be.false; + }); +}); + diff --git a/test/content/repositories/FindRepository.test.ts b/test/content/repositories/FindRepository.test.ts new file mode 100644 index 0000000..dcb2dff --- /dev/null +++ b/test/content/repositories/FindRepository.test.ts @@ -0,0 +1,15 @@ +import { FindRepositoryImpl } from '../../../src/content/repositories/FindRepository'; +import { expect } from 'chai'; + +describe('FindRepositoryImpl', () => { + it('updates and gets last keyword', () => { + let sut = new FindRepositoryImpl(); + + expect(sut.getLastKeyword()).to.be.null; + + sut.setLastKeyword('monkey'); + + expect(sut.getLastKeyword()).to.equal('monkey'); + }); +}); + diff --git a/test/content/repositories/FollowKeyRepository.test.ts b/test/content/repositories/FollowKeyRepository.test.ts new file mode 100644 index 0000000..eae58b9 --- /dev/null +++ b/test/content/repositories/FollowKeyRepository.test.ts @@ -0,0 +1,31 @@ +import FollowKeyRepository, { FollowKeyRepositoryImpl } + from '../../../src/content/repositories/FollowKeyRepository'; +import { expect } from 'chai'; + +describe('FollowKeyRepositoryImpl', () => { + let sut: FollowKeyRepository; + + before(() => { + sut = new FollowKeyRepositoryImpl(); + }); + + describe('#getKeys()/#pushKey()/#popKey()', () => { + it('enqueues keys', () => { + expect(sut.getKeys()).to.be.empty; + + sut.pushKey('a'); + sut.pushKey('b'); + sut.pushKey('c'); + expect(sut.getKeys()).to.deep.equal(['a', 'b', 'c']); + + sut.popKey(); + expect(sut.getKeys()).to.deep.equal(['a', 'b']); + + sut.clearKeys(); + expect(sut.getKeys()).to.be.empty; + }); + }); +}); + + + diff --git a/test/content/repositories/FollowMasterRepository.test.ts b/test/content/repositories/FollowMasterRepository.test.ts new file mode 100644 index 0000000..8c3f34e --- /dev/null +++ b/test/content/repositories/FollowMasterRepository.test.ts @@ -0,0 +1,49 @@ +import FollowMasterRepository, { FollowMasterRepositoryImpl } + from '../../../src/content/repositories/FollowMasterRepository'; +import { expect } from 'chai'; + +describe('FollowMasterRepositoryImpl', () => { + let sut: FollowMasterRepository; + + before(() => { + sut = new FollowMasterRepositoryImpl(); + }); + + describe('#getTags()/#addTag()/#clearTags()', () => { + it('gets, adds and clears tags', () => { + expect(sut.getTags()).to.be.empty; + + sut.addTag('a'); + sut.addTag('b'); + sut.addTag('c'); + expect(sut.getTags()).to.deep.equal(['a', 'b', 'c']); + + sut.clearTags(); + expect(sut.getTags()).to.be.empty; + }); + }); + + describe('#getTagsByPrefix', () => { + it('gets tags matched by prefix', () => { + for (let tag of ['a', 'aa', 'ab', 'b', 'ba', 'bb']) { + sut.addTag(tag); + } + expect(sut.getTagsByPrefix('a')).to.deep.equal(['a', 'aa', 'ab']); + expect(sut.getTagsByPrefix('aa')).to.deep.equal(['aa']); + expect(sut.getTagsByPrefix('b')).to.deep.equal(['b', 'ba', 'bb']); + expect(sut.getTagsByPrefix('c')).to.be.empty; + }); + }); + + describe('#setCurrentFollowMode()/#getCurrentNewTabMode()/#getCurrentBackgroundMode', () => { + it('updates and gets follow mode', () => { + sut.setCurrentFollowMode(false, true); + expect(sut.getCurrentNewTabMode()).to.be.false; + expect(sut.getCurrentBackgroundMode()).to.be.true; + + sut.setCurrentFollowMode(true, false); + expect(sut.getCurrentNewTabMode()).to.be.true; + expect(sut.getCurrentBackgroundMode()).to.be.false; + }); + }); +}); diff --git a/test/content/repositories/FollowSlaveRepository.test.ts b/test/content/repositories/FollowSlaveRepository.test.ts new file mode 100644 index 0000000..10cf094 --- /dev/null +++ b/test/content/repositories/FollowSlaveRepository.test.ts @@ -0,0 +1,24 @@ +import FollowSlaveRepository, { FollowSlaveRepositoryImpl } + from '../../../src/content/repositories/FollowSlaveRepository'; +import { expect } from 'chai'; + +describe('FollowSlaveRepository', () => { + let sut: FollowSlaveRepository; + + before(() => { + sut = new FollowSlaveRepositoryImpl(); + }); + + describe('#isFollowMode()/#enableFollowMode()/#disableFollowMode()', () => { + it('gets, adds updates follow mode', () => { + expect(sut.isFollowMode()).to.be.false; + + sut.enableFollowMode(); + expect(sut.isFollowMode()).to.be.true; + + sut.disableFollowMode(); + expect(sut.isFollowMode()).to.be.false; + }); + }); +}); + diff --git a/test/content/repositories/KeymapRepository.test.ts b/test/content/repositories/KeymapRepository.test.ts new file mode 100644 index 0000000..34704d9 --- /dev/null +++ b/test/content/repositories/KeymapRepository.test.ts @@ -0,0 +1,37 @@ +import KeymapRepository, { KeymapRepositoryImpl } + from '../../../src/content/repositories/KeymapRepository'; +import { expect } from 'chai'; + +describe('KeymapRepositoryImpl', () => { + let sut: KeymapRepository; + + before(() => { + sut = new KeymapRepositoryImpl(); + }); + + describe('#enqueueKey()', () => { + it('enqueues keys', () => { + sut.enqueueKey({ key: 'a' }); + sut.enqueueKey({ key: 'b' }); + let sequence = sut.enqueueKey({ key: 'c' }); + + expect(sequence.getKeyArray()).deep.equals([ + { key: 'a' }, { key: 'b' }, { key: 'c' }, + ]); + }); + }); + + describe('#clear()', () => { + it('clears keys', () => { + sut.enqueueKey({ key: 'a' }); + sut.enqueueKey({ key: 'b' }); + sut.enqueueKey({ key: 'c' }); + sut.clear(); + + let sequence = sut.enqueueKey({ key: 'a' }); + expect(sequence.length()).to.equal(1); + }); + }); +}); + + diff --git a/test/content/repositories/MarkKeyRepository.test.ts b/test/content/repositories/MarkKeyRepository.test.ts new file mode 100644 index 0000000..8592332 --- /dev/null +++ b/test/content/repositories/MarkKeyRepository.test.ts @@ -0,0 +1,36 @@ +import MarkRepository, { MarkKeyRepositoryImpl } + from '../../../src/content/repositories/MarkKeyRepository'; +import { expect } from 'chai'; + +describe('MarkKeyRepositoryImpl', () => { + let sut: MarkRepository; + + before(() => { + sut = new MarkKeyRepositoryImpl(); + }) + + describe('#isSetMode/#enableSetMode/#disabeSetMode', () => { + it('enables and disables set mode', () => { + expect(sut.isSetMode()).to.be.false; + + sut.enableSetMode(); + expect(sut.isSetMode()).to.be.true; + + sut.disabeSetMode(); + expect(sut.isSetMode()).to.be.false; + }); + }); + + describe('#isJumpMode/#enableJumpMode/#disabeJumpMode', () => { + it('enables and disables jump mode', () => { + expect(sut.isJumpMode()).to.be.false; + + sut.enableJumpMode(); + expect(sut.isJumpMode()).to.be.true; + + sut.disabeJumpMode(); + expect(sut.isJumpMode()).to.be.false; + }); + }); +}); + diff --git a/test/content/repositories/MarkRepository.test.ts b/test/content/repositories/MarkRepository.test.ts new file mode 100644 index 0000000..7fced5f --- /dev/null +++ b/test/content/repositories/MarkRepository.test.ts @@ -0,0 +1,13 @@ +import { MarkRepositoryImpl } from '../../../src/content/repositories/MarkRepository'; +import { expect } from 'chai'; + +describe('MarkRepositoryImpl', () => { + it('save and load marks', () => { + let sut = new MarkRepositoryImpl(); + + sut.set('a', { x: 10, y: 20 }); + expect(sut.get('a')).to.deep.equal({ x: 10, y: 20 }); + expect(sut.get('b')).to.be.null; + }); +}); + diff --git a/test/content/repositories/SettingRepository.test.ts b/test/content/repositories/SettingRepository.test.ts new file mode 100644 index 0000000..fea70b7 --- /dev/null +++ b/test/content/repositories/SettingRepository.test.ts @@ -0,0 +1,30 @@ +import { SettingRepositoryImpl } from '../../../src/content/repositories/SettingRepository'; +import { expect } from 'chai'; + +describe('SettingRepositoryImpl', () => { + it('updates and gets current value', () => { + let sut = new SettingRepositoryImpl(); + + let settings = { + keymaps: {}, + search: { + default: 'google', + engines: { + google: 'https://google.com/?q={}', + } + }, + properties: { + hintchars: 'abcd1234', + smoothscroll: false, + complete: 'sbh', + }, + blacklist: [], + } + + sut.set(settings); + + let actual = sut.get(); + expect(actual.properties.hintchars).to.equal('abcd1234'); + }); +}); + diff --git a/test/content/usecases/AddonEnabledUseCase.test.ts b/test/content/usecases/AddonEnabledUseCase.test.ts new file mode 100644 index 0000000..912bddf --- /dev/null +++ b/test/content/usecases/AddonEnabledUseCase.test.ts @@ -0,0 +1,90 @@ +import AddonEnabledRepository from '../../../src/content/repositories/AddonEnabledRepository'; +import AddonEnabledUseCase from '../../../src/content/usecases/AddonEnabledUseCase'; +import AddonIndicatorClient from '../../../src/content/client/AddonIndicatorClient'; +import { expect } from 'chai'; + +class MockAddonEnabledRepository implements AddonEnabledRepository { + private enabled: boolean; + + constructor(init: boolean) { + this.enabled = init; + } + + set(on: boolean): void { + this.enabled = on; + } + + get(): boolean { + return this.enabled; + } +} + +class MockAddonIndicatorClient implements AddonIndicatorClient { + public enabled: boolean; + + constructor(init: boolean) { + this.enabled = init; + } + + async setEnabled(enabled: boolean): Promise<void> { + this.enabled = enabled; + return + } +} + +describe('AddonEnabledUseCase', () => { + let repository: MockAddonEnabledRepository; + let indicator: MockAddonIndicatorClient; + let sut: AddonEnabledUseCase; + + beforeEach(() => { + repository = new MockAddonEnabledRepository(true); + indicator = new MockAddonIndicatorClient(false); + sut = new AddonEnabledUseCase({ repository, indicator }); + }); + + describe('#enable', () => { + it('store and indicate as enabled', async() => { + await sut.enable(); + + expect(repository.get()).to.be.true; + expect(indicator.enabled).to.be.true; + }); + }); + + describe('#disable', async() => { + it('store and indicate as disabled', async() => { + await sut.disable(); + + expect(repository.get()).to.be.false; + expect(indicator.enabled).to.be.false; + }); + }); + + describe('#toggle', () => { + it('toggled enabled and disabled', async() => { + repository.set(true); + await sut.toggle(); + + expect(repository.get()).to.be.false; + expect(indicator.enabled).to.be.false; + + repository.set(false); + + await sut.toggle(); + + expect(repository.get()).to.be.true; + expect(indicator.enabled).to.be.true; + }); + }); + + describe('#getEnabled', () => { + it('returns current addon enabled', () => { + repository.set(true); + expect(sut.getEnabled()).to.be.true; + + repository.set(false); + expect(sut.getEnabled()).to.be.false; + }); + }); +}); diff --git a/test/content/usecases/ClipboardUseCase.test.ts b/test/content/usecases/ClipboardUseCase.test.ts new file mode 100644 index 0000000..862ee8a --- /dev/null +++ b/test/content/usecases/ClipboardUseCase.test.ts @@ -0,0 +1,76 @@ +import ClipboardRepository from '../../../src/content/repositories/ClipboardRepository'; +import TabsClient from '../../../src/content/client/TabsClient'; +import MockConsoleClient from '../mock/MockConsoleClient'; +import ClipboardUseCase from '../../../src/content/usecases/ClipboardUseCase'; +import { expect } from 'chai'; + +class MockClipboardRepository implements ClipboardRepository { + public clipboard: string; + + constructor() { + this.clipboard = ''; + } + + read(): string { + return this.clipboard; + } + + write(text: string): void { + this.clipboard = text; + } +} + +class MockTabsClient implements TabsClient { + public last: string; + + constructor() { + this.last = ''; + } + + openUrl(url: string, _newTab: boolean): Promise<void> { + this.last = url; + return Promise.resolve(); + } +} + +describe('ClipboardUseCase', () => { + let repository: MockClipboardRepository; + let client: MockTabsClient; + let consoleClient: MockConsoleClient; + let sut: ClipboardUseCase; + + beforeEach(() => { + repository = new MockClipboardRepository(); + client = new MockTabsClient(); + consoleClient = new MockConsoleClient(); + sut = new ClipboardUseCase({ repository, client: client, consoleClient }); + }); + + describe('#yankCurrentURL', () => { + it('yanks current url', async () => { + let yanked = await sut.yankCurrentURL(); + + expect(yanked).to.equal(window.location.href); + expect(repository.clipboard).to.equal(yanked); + expect(consoleClient.text).to.equal('Yanked ' + yanked); + }); + }); + + describe('#openOrSearch', () => { + it('opens url from the clipboard', async () => { + let url = 'https://github.com/ueokande/vim-vixen' + repository.clipboard = url; + await sut.openOrSearch(true); + + expect(client.last).to.equal(url); + }); + + it('opens search results from the clipboard', async () => { + repository.clipboard = 'banana'; + await sut.openOrSearch(true); + + expect(client.last).to.equal('https://google.com/search?q=banana'); + }); + }); +}); + diff --git a/test/content/usecases/FindUseCase.test.ts b/test/content/usecases/FindUseCase.test.ts new file mode 100644 index 0000000..c7bfd39 --- /dev/null +++ b/test/content/usecases/FindUseCase.test.ts @@ -0,0 +1,161 @@ +import FindRepository from '../../../src/content/repositories/FindRepository'; +import FindPresenter from '../../../src/content/presenters/FindPresenter'; +import FindClient from '../../../src/content/client/FindClient'; +import FindUseCase from '../../../src/content/usecases/FindUseCase'; +import MockConsoleClient from '../mock/MockConsoleClient'; +import { expect } from 'chai'; + +class MockFindRepository implements FindRepository { + public keyword: string | null; + + constructor() { + this.keyword = null; + } + + getLastKeyword(): string | null { + return this.keyword; + } + + setLastKeyword(keyword: string): void { + this.keyword = keyword; + } +} + +class MockFindPresenter implements FindPresenter { + public document: string; + + public highlighted: boolean; + + constructor() { + this.document = ''; + this.highlighted = false; + } + + find(keyword: string, _backward: boolean): boolean { + let found = this.document.includes(keyword); + this.highlighted = found; + return found; + } + + clearSelection(): void { + this.highlighted = false; + } +} + +class MockFindClient implements FindClient { + public keyword: string | null; + + constructor() { + this.keyword = null; + } + + getGlobalLastKeyword(): Promise<string | null> { + return Promise.resolve(this.keyword); + } + + setGlobalLastKeyword(keyword: string): Promise<void> { + this.keyword = keyword; + return Promise.resolve(); + } +} + +describe('FindUseCase', () => { + let repository: MockFindRepository; + let presenter: MockFindPresenter; + let client: MockFindClient; + let consoleClient: MockConsoleClient; + let sut: FindUseCase; + + beforeEach(() => { + repository = new MockFindRepository(); + presenter = new MockFindPresenter(); + client = new MockFindClient(); + consoleClient = new MockConsoleClient(); + sut = new FindUseCase({ repository, presenter, client, consoleClient }); + }); + + describe('#startFind', () => { + it('find next by ketword', async() => { + presenter.document = 'monkey punch'; + + await sut.startFind('monkey'); + + expect(await presenter.highlighted).to.be.true; + expect(await consoleClient.text).to.equal('Pattern found: monkey'); + expect(await repository.getLastKeyword()).to.equal('monkey'); + expect(await client.getGlobalLastKeyword()).to.equal('monkey'); + }); + + it('find next by last keyword', async() => { + presenter.document = 'gorilla kick'; + repository.keyword = 'gorilla'; + + await sut.startFind(undefined); + + expect(await presenter.highlighted).to.be.true; + expect(await consoleClient.text).to.equal('Pattern found: gorilla'); + expect(await repository.getLastKeyword()).to.equal('gorilla'); + expect(await client.getGlobalLastKeyword()).to.equal('gorilla'); + }); + + it('find next by global last keyword', async() => { + presenter.document = 'chimpanzee typing'; + + repository.keyword = null; + client.keyword = 'chimpanzee'; + + await sut.startFind(undefined); + + expect(await presenter.highlighted).to.be.true; + expect(await consoleClient.text).to.equal('Pattern found: chimpanzee'); + expect(await repository.getLastKeyword()).to.equal('chimpanzee'); + expect(await client.getGlobalLastKeyword()).to.equal('chimpanzee'); + }); + + it('find not found error', async() => { + presenter.document = 'zoo'; + + await sut.startFind('giraffe'); + + expect(await presenter.highlighted).to.be.false; + expect(await consoleClient.text).to.equal('Pattern not found: giraffe'); + expect(await repository.getLastKeyword()).to.equal('giraffe'); + expect(await client.getGlobalLastKeyword()).to.equal('giraffe'); + }); + + it('show errors when no last keywords', async() => { + repository.keyword = null; + client.keyword = null; + + await sut.startFind(undefined); + + expect(await consoleClient.text).to.equal('No previous search keywords'); + expect(await consoleClient.isError).to.be.true; + }); + }); + + describe('#findNext', () => { + it('finds by last keyword', async() => { + presenter.document = 'monkey punch'; + repository.keyword = 'monkey'; + + await sut.findNext(); + + expect(await presenter.highlighted).to.be.true; + expect(await consoleClient.text).to.equal('Pattern found: monkey'); + }); + + it('show errors when no last keywords', async() => { + repository.keyword = null; + client.keyword = null; + + await sut.findNext(); + + expect(await consoleClient.text).to.equal('No previous search keywords'); + expect(await consoleClient.isError).to.be.true; + }); + }); + + describe('#findPrev', () => { + }); +}); diff --git a/test/content/hint-key-producer.test.ts b/test/content/usecases/HintKeyProducer.test.ts index dcf477d..feafffb 100644 --- a/test/content/hint-key-producer.test.ts +++ b/test/content/usecases/HintKeyProducer.test.ts @@ -1,9 +1,10 @@ -import HintKeyProducer from 'content/hint-key-producer'; +import HintKeyProducer from '../../../src/content/usecases/HintKeyProducer'; +import { expect } from 'chai'; describe('HintKeyProducer class', () => { describe('#constructor', () => { it('throws an exception on empty charset', () => { - expect(() => new HintKeyProducer([])).to.throw(TypeError); + expect(() => new HintKeyProducer('')).to.throw(TypeError); }); }); diff --git a/test/content/usecases/MarkUseCase.test.ts b/test/content/usecases/MarkUseCase.test.ts new file mode 100644 index 0000000..4f2dee4 --- /dev/null +++ b/test/content/usecases/MarkUseCase.test.ts @@ -0,0 +1,107 @@ +import MarkRepository from '../../../src/content/repositories/MarkRepository'; +import MarkUseCase from '../../../src/content/usecases/MarkUseCase'; +import MarkClient from '../../../src/content/client/MarkClient'; +import MockConsoleClient from '../mock/MockConsoleClient'; +import MockScrollPresenter from '../mock/MockScrollPresenter'; +import Mark from '../../../src/content/domains/Mark'; +import { expect } from 'chai'; + +class MockMarkRepository implements MarkRepository { + private current: {[key: string]: Mark}; + + constructor() { + this.current = {}; + } + + set(key: string, mark: Mark): void { + this.current[key] = mark; + } + + get(key: string): Mark | null { + return this.current[key]; + } +} + +class MockMarkClient implements MarkClient { + public marks: {[key: string]: Mark}; + public last: string; + + constructor() { + this.marks = {}; + this.last = ''; + } + + setGloablMark(key: string, mark: Mark): Promise<void> { + this.marks[key] = mark; + return Promise.resolve(); + } + + jumpGlobalMark(key: string): Promise<void> { + this.last = key + return Promise.resolve(); + } +} + +describe('MarkUseCase', () => { + let repository: MockMarkRepository; + let client: MockMarkClient; + let consoleClient: MockConsoleClient; + let scrollPresenter: MockScrollPresenter; + let sut: MarkUseCase; + + beforeEach(() => { + repository = new MockMarkRepository(); + client = new MockMarkClient(); + consoleClient = new MockConsoleClient(); + scrollPresenter = new MockScrollPresenter(); + sut = new MarkUseCase({ + repository, client, consoleClient, scrollPresenter, + }); + }); + + describe('#set', () => { + it('sets local mark', async() => { + scrollPresenter.scrollTo(10, 20, false); + + await sut.set('x'); + + expect(repository.get('x')).to.deep.equals({ x: 10, y: 20 }); + expect(consoleClient.text).to.equal("Set local mark to 'x'"); + }); + + it('sets global mark', async() => { + scrollPresenter.scrollTo(30, 40, false); + + await sut.set('Z'); + + expect(client.marks['Z']).to.deep.equals({ x: 30, y: 40 }); + expect(consoleClient.text).to.equal("Set global mark to 'Z'"); + }); + }); + + describe('#jump', () => { + it('jumps to local mark', async() => { + repository.set('x', { x: 20, y: 40 }); + + await sut.jump('x'); + + expect(scrollPresenter.getScroll()).to.deep.equals({ x: 20, y: 40 }); + }); + + it('throws an error when no local marks', () => { + return sut.jump('a').then(() => { + throw new Error('error'); + }).catch((e) => { + expect(e).to.be.instanceof(Error); + }) + }) + + it('jumps to global mark', async() => { + client.marks['Z'] = { x: 20, y: 0 }; + + await sut.jump('Z'); + + expect(client.last).to.equal('Z') + }); + }); +}); diff --git a/test/content/usecases/SettingUseCaase.test.ts b/test/content/usecases/SettingUseCaase.test.ts new file mode 100644 index 0000000..02cef78 --- /dev/null +++ b/test/content/usecases/SettingUseCaase.test.ts @@ -0,0 +1,71 @@ +import SettingRepository from '../../../src/content/repositories/SettingRepository'; +import SettingClient from '../../../src/content/client/SettingClient'; +import SettingUseCase from '../../../src/content/usecases/SettingUseCase'; +import Settings, { DefaultSetting } from '../../../src/shared/Settings'; +import { expect } from 'chai'; + +class MockSettingRepository implements SettingRepository { + private current: Settings; + + constructor() { + this.current = DefaultSetting; + } + + set(settings: Settings): void { + this.current= settings; + } + + get(): Settings { + return this.current; + } +} + +class MockSettingClient implements SettingClient { + private data: Settings; + + constructor(data: Settings) { + this.data = data; + } + + load(): Promise<Settings> { + return Promise.resolve(this.data); + } +} + +describe('AddonEnabledUseCase', () => { + let repository: MockSettingRepository; + let client: MockSettingClient; + let sut: SettingUseCase; + + beforeEach(() => { + let testSettings = { + keymaps: {}, + search: { + default: 'google', + engines: { + google: 'https://google.com/?q={}', + } + }, + properties: { + hintchars: 'abcd1234', + smoothscroll: false, + complete: 'sbh', + }, + blacklist: [], + }; + + repository = new MockSettingRepository(); + client = new MockSettingClient(testSettings); + sut = new SettingUseCase({ repository, client }); + }); + + describe('#reload', () => { + it('loads settings and store to repository', async() => { + let settings = await sut.reload(); + expect(settings.properties.hintchars).to.equal('abcd1234'); + + let saved = repository.get(); + expect(saved.properties.hintchars).to.equal('abcd1234'); + }); + }); +}); |