From 1ba1660269b24446e9df7df0016de8c3e5596c8f Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 11 May 2019 11:37:18 +0900 Subject: Make find as a clean architecture --- src/content/presenters/FindPresenter.ts | 59 +++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/content/presenters/FindPresenter.ts (limited to 'src/content/presenters') diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts new file mode 100644 index 0000000..6dd03f8 --- /dev/null +++ b/src/content/presenters/FindPresenter.ts @@ -0,0 +1,59 @@ +import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient'; + +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 { + private consoleClient: ConsoleClient; + + constructor({ consoleClient = new ConsoleClientImpl() } = {}) { + this.consoleClient = consoleClient; + } + + 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(); + } + } +} -- cgit v1.2.3 From ad1f3c07fbb90c4e69cc2374d74a7373e4da70f2 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 11 May 2019 11:51:29 +0900 Subject: Make scroller as a presenter --- src/content/actions/operation.ts | 17 +-- src/content/components/common/mark.ts | 9 +- src/content/components/top-content/index.ts | 5 +- src/content/presenters/FindPresenter.ts | 7 -- src/content/presenters/ScrollPresenter.ts | 179 ++++++++++++++++++++++++++++ src/content/scrolls.ts | 168 -------------------------- 6 files changed, 196 insertions(+), 189 deletions(-) create mode 100644 src/content/presenters/ScrollPresenter.ts delete mode 100644 src/content/scrolls.ts (limited to 'src/content/presenters') diff --git a/src/content/actions/operation.ts b/src/content/actions/operation.ts index f65d0bd..b264e36 100644 --- a/src/content/actions/operation.ts +++ b/src/content/actions/operation.ts @@ -1,7 +1,6 @@ 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'; @@ -10,9 +9,11 @@ import * as markActions from './mark'; import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; import { SettingRepositoryImpl } from '../repositories/SettingRepository'; +import { ScrollPresenterImpl } from '../presenters/ScrollPresenter'; let addonEnabledUseCase = new AddonEnabledUseCase(); let settingRepository = new SettingRepositoryImpl(); +let scrollPresenter = new ScrollPresenterImpl(); // eslint-disable-next-line complexity, max-lines-per-function const exec = async( @@ -41,25 +42,25 @@ const exec = async( }), '*'); break; case operations.SCROLL_VERTICALLY: - scrolls.scrollVertically(operation.count, smoothscroll); + scrollPresenter.scrollVertically(operation.count, smoothscroll); break; case operations.SCROLL_HORIZONALLY: - scrolls.scrollHorizonally(operation.count, smoothscroll); + scrollPresenter.scrollHorizonally(operation.count, smoothscroll); break; case operations.SCROLL_PAGES: - scrolls.scrollPages(operation.count, smoothscroll); + scrollPresenter.scrollPages(operation.count, smoothscroll); break; case operations.SCROLL_TOP: - scrolls.scrollToTop(smoothscroll); + scrollPresenter.scrollToTop(smoothscroll); break; case operations.SCROLL_BOTTOM: - scrolls.scrollToBottom(smoothscroll); + scrollPresenter.scrollToBottom(smoothscroll); break; case operations.SCROLL_HOME: - scrolls.scrollToHome(smoothscroll); + scrollPresenter.scrollToHome(smoothscroll); break; case operations.SCROLL_END: - scrolls.scrollToEnd(smoothscroll); + scrollPresenter.scrollToEnd(smoothscroll); break; case operations.FOLLOW_START: window.top.postMessage(JSON.stringify({ diff --git a/src/content/components/common/mark.ts b/src/content/components/common/mark.ts index 77aa15d..ddd1a38 100644 --- a/src/content/components/common/mark.ts +++ b/src/content/components/common/mark.ts @@ -1,12 +1,13 @@ 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'; import { SettingRepositoryImpl } from '../../repositories/SettingRepository'; +import { ScrollPresenterImpl } from '../../presenters/ScrollPresenter'; let settingRepository = new SettingRepositoryImpl(); +let scrollPresenter = new ScrollPresenterImpl(); const cancelKey = (key: keyUtils.Key): boolean => { return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey); @@ -54,7 +55,7 @@ export default class MarkComponent { } doSet(key: keyUtils.Key) { - let { x, y } = scrolls.getScroll(); + let { x, y } = scrollPresenter.getScroll(); this.store.dispatch(markActions.setLocal(key.key, x, y)); } @@ -69,11 +70,11 @@ export default class MarkComponent { } let { x, y } = marks[key.key]; - scrolls.scrollTo(x, y, smoothscroll); + scrollPresenter.scrollTo(x, y, smoothscroll); } doSetGlobal(key: keyUtils.Key) { - let { x, y } = scrolls.getScroll(); + let { x, y } = scrollPresenter.getScroll(); this.store.dispatch(markActions.setGlobal(key.key, x, y)); } diff --git a/src/content/components/top-content/index.ts b/src/content/components/top-content/index.ts index b9ef2dd..de14b3f 100644 --- a/src/content/components/top-content/index.ts +++ b/src/content/components/top-content/index.ts @@ -4,10 +4,11 @@ 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'; import AddonEnabledUseCase from '../../usecases/AddonEnabledUseCase'; +import { ScrollPresenterImpl } from '../../presenters/ScrollPresenter'; let addonEnabledUseCase = new AddonEnabledUseCase(); +let scrollPresenter = new ScrollPresenterImpl(); export default class TopContent { private win: Window; @@ -42,7 +43,7 @@ export default class TopContent { case messages.ADDON_ENABLED_QUERY: return Promise.resolve(addonEnabled); case messages.TAB_SCROLL_TO: - return scrolls.scrollTo(message.x, message.y, false); + return scrollPresenter.scrollTo(message.x, message.y, false); } } } diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts index 6dd03f8..d9bc835 100644 --- a/src/content/presenters/FindPresenter.ts +++ b/src/content/presenters/FindPresenter.ts @@ -1,4 +1,3 @@ -import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient'; export default interface FindPresenter { find(keyword: string, backwards: boolean): boolean; @@ -28,12 +27,6 @@ interface MyWindow extends Window { declare var window: MyWindow; export class FindPresenterImpl implements FindPresenter { - private consoleClient: ConsoleClient; - - constructor({ consoleClient = new ConsoleClientImpl() } = {}) { - this.consoleClient = consoleClient; - } - find(keyword: string, backwards: boolean): boolean { let caseSensitive = false; let wrapScan = true; diff --git a/src/content/presenters/ScrollPresenter.ts b/src/content/presenters/ScrollPresenter.ts new file mode 100644 index 0000000..9f47394 --- /dev/null +++ b/src/content/presenters/ScrollPresenter.ts @@ -0,0 +1,179 @@ +import * as doms from '../../shared/utils/dom'; + +const SCROLL_DELTA_X = 64; +const SCROLL_DELTA_Y = 64; + +// dirty way to store scrolling state on globally +let scrolling = false; +let lastTimeoutId: number | null = null; + +const isScrollableStyle = (element: Element): boolean => { + let { overflowX, overflowY } = window.getComputedStyle(element); + return !(overflowX !== 'scroll' && overflowX !== 'auto' && + overflowY !== 'scroll' && overflowY !== 'auto'); +}; + +const isOverflowed = (element: Element): boolean => { + return element.scrollWidth > element.clientWidth || + element.scrollHeight > element.clientHeight; +}; + +// Find a visiable and scrollable element by depth-first search. Currently +// this method is called by each scrolling, and the returned value of this +// method is not cached. That does not cause performance issue because in the +// most pages, the window is root element i,e, documentElement. +const findScrollable = (element: Element): Element | null => { + if (isScrollableStyle(element) && isOverflowed(element)) { + return element; + } + + let children = Array.from(element.children).filter(doms.isVisible); + for (let child of children) { + let scrollable = findScrollable(child); + if (scrollable) { + return scrollable; + } + } + return null; +}; + +const scrollTarget = () => { + if (isOverflowed(window.document.documentElement)) { + return window.document.documentElement; + } + if (isOverflowed(window.document.body)) { + return window.document.body; + } + let target = findScrollable(window.document.documentElement); + if (target) { + return target; + } + return window.document.documentElement; +}; + +const resetScrolling = () => { + scrolling = false; +}; + +class Scroller { + private element: Element; + + private smooth: boolean; + + constructor(element: Element, smooth: boolean) { + this.element = element; + this.smooth = smooth; + } + + scrollTo(x: number, y: number): void { + if (!this.smooth) { + this.element.scrollTo(x, y); + return; + } + this.element.scrollTo({ + left: x, + top: y, + behavior: 'smooth', + }); + this.prepareReset(); + } + + scrollBy(x: number, y: number): void { + let left = this.element.scrollLeft + x; + let top = this.element.scrollTop + y; + this.scrollTo(left, top); + } + + prepareReset(): void { + scrolling = true; + if (lastTimeoutId) { + clearTimeout(lastTimeoutId); + lastTimeoutId = null; + } + lastTimeoutId = setTimeout(resetScrolling, 100); + } +} + +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 +} + +export class ScrollPresenterImpl { + getScroll(): Point { + let target = scrollTarget(); + return { x: target.scrollLeft, y: target.scrollTop }; + } + + 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); + } + + 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); + } + + 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); + } + + scrollTo(x: number, y: number, smooth: boolean): void { + let target = scrollTarget(); + 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); + } + + scrollToBottom(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollLeft; + let y = target.scrollHeight; + 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); + } + + 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/scrolls.ts b/src/content/scrolls.ts deleted file mode 100644 index 6a35315..0000000 --- a/src/content/scrolls.ts +++ /dev/null @@ -1,168 +0,0 @@ -import * as doms from '../shared/utils/dom'; - -const SCROLL_DELTA_X = 64; -const SCROLL_DELTA_Y = 64; - -// dirty way to store scrolling state on globally -let scrolling = false; -let lastTimeoutId: number | null = null; - -const isScrollableStyle = (element: Element): boolean => { - let { overflowX, overflowY } = window.getComputedStyle(element); - return !(overflowX !== 'scroll' && overflowX !== 'auto' && - overflowY !== 'scroll' && overflowY !== 'auto'); -}; - -const isOverflowed = (element: Element): boolean => { - return element.scrollWidth > element.clientWidth || - element.scrollHeight > element.clientHeight; -}; - -// Find a visiable and scrollable element by depth-first search. Currently -// this method is called by each scrolling, and the returned value of this -// method is not cached. That does not cause performance issue because in the -// most pages, the window is root element i,e, documentElement. -const findScrollable = (element: Element): Element | null => { - if (isScrollableStyle(element) && isOverflowed(element)) { - return element; - } - - let children = Array.from(element.children).filter(doms.isVisible); - for (let child of children) { - let scrollable = findScrollable(child); - if (scrollable) { - return scrollable; - } - } - return null; -}; - -const scrollTarget = () => { - if (isOverflowed(window.document.documentElement)) { - return window.document.documentElement; - } - if (isOverflowed(window.document.body)) { - return window.document.body; - } - let target = findScrollable(window.document.documentElement); - if (target) { - return target; - } - return window.document.documentElement; -}; - -const resetScrolling = () => { - scrolling = false; -}; - -class Scroller { - private element: Element; - - private smooth: boolean; - - constructor(element: Element, smooth: boolean) { - this.element = element; - this.smooth = smooth; - } - - scrollTo(x: number, y: number): void { - if (!this.smooth) { - this.element.scrollTo(x, y); - return; - } - this.element.scrollTo({ - left: x, - top: y, - behavior: 'smooth', - }); - this.prepareReset(); - } - - scrollBy(x: number, y: number): void { - let left = this.element.scrollLeft + x; - let top = this.element.scrollTop + y; - this.scrollTo(left, top); - } - - prepareReset(): void { - scrolling = true; - if (lastTimeoutId) { - clearTimeout(lastTimeoutId); - lastTimeoutId = null; - } - lastTimeoutId = setTimeout(resetScrolling, 100); - } -} - -const getScroll = () => { - let target = scrollTarget(); - return { x: target.scrollLeft, y: target.scrollTop }; -}; - -const 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); -}; - -const 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); -}; - -const 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 scrollTo = (x: number, y: number, smooth: boolean): void => { - let target = scrollTarget(); - new Scroller(target, smooth).scrollTo(x, y); -}; - -const scrollToTop = (smooth: boolean): void => { - let target = scrollTarget(); - let x = target.scrollLeft; - let y = 0; - 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); -}; - -const scrollToHome = (smooth: boolean): void => { - let target = scrollTarget(); - let x = 0; - let y = target.scrollTop; - 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); -}; - -export { - getScroll, - scrollVertically, scrollHorizonally, scrollPages, - scrollTo, - scrollToTop, scrollToBottom, scrollToHome, scrollToEnd -}; -- cgit v1.2.3 From c6288f19d93a05f96274dd172450b8350389c39f Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 11 May 2019 16:38:08 +0900 Subject: Mark set/jump as a clean architecture --- src/content/Mark.ts | 6 -- src/content/actions/index.ts | 11 +-- src/content/actions/mark.ts | 31 +------ src/content/client/MarkClient.ts | 28 ++++++ src/content/components/common/mark.ts | 48 +--------- src/content/domains/Mark.ts | 6 ++ src/content/presenters/ScrollPresenter.ts | 2 +- src/content/reducers/mark.ts | 8 -- src/content/repositories/MarkRepository.ts | 25 ++++++ src/content/usecases/MarkUseCase.ts | 62 +++++++++++++ test/content/actions/mark.test.ts | 10 --- test/content/mock/MockConsoleClient.ts | 26 ++++++ test/content/mock/MockScrollPresenter.ts | 47 ++++++++++ test/content/reducers/mark.test.ts | 10 --- test/content/repositories/MarkRepository.test.ts | 13 +++ test/content/usecases/FindUseCase.test.ts | 25 +----- test/content/usecases/MarkUseCase.test.ts | 107 +++++++++++++++++++++++ 17 files changed, 322 insertions(+), 143 deletions(-) delete mode 100644 src/content/Mark.ts create mode 100644 src/content/client/MarkClient.ts create mode 100644 src/content/domains/Mark.ts create mode 100644 src/content/repositories/MarkRepository.ts create mode 100644 src/content/usecases/MarkUseCase.ts create mode 100644 test/content/mock/MockConsoleClient.ts create mode 100644 test/content/mock/MockScrollPresenter.ts create mode 100644 test/content/repositories/MarkRepository.test.ts create mode 100644 test/content/usecases/MarkUseCase.test.ts (limited to 'src/content/presenters') diff --git a/src/content/Mark.ts b/src/content/Mark.ts deleted file mode 100644 index f1282fc..0000000 --- a/src/content/Mark.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default interface Mark { - x: number; - y: number; - // eslint-disable-next-line semi -} - diff --git a/src/content/actions/index.ts b/src/content/actions/index.ts index f6d19aa..eb826fc 100644 --- a/src/content/actions/index.ts +++ b/src/content/actions/index.ts @@ -20,7 +20,6 @@ export const FOLLOW_CONTROLLER_BACKSPACE = 'follow.controller.backspace'; 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'; @@ -64,13 +63,6 @@ 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; } @@ -80,8 +72,7 @@ export type FollowAction = FollowControllerEnableAction | FollowControllerDisableAction | FollowControllerKeyPressAction | FollowControllerBackspaceAction; export type MarkAction = - MarkStartSetAction | MarkStartJumpAction | - MarkCancelAction | MarkSetLocalAction | NoopAction; + MarkStartSetAction | MarkStartJumpAction | MarkCancelAction | NoopAction; export type Action = InputAction | diff --git a/src/content/actions/mark.ts b/src/content/actions/mark.ts index 5eb9554..1068507 100644 --- a/src/content/actions/mark.ts +++ b/src/content/actions/mark.ts @@ -1,5 +1,4 @@ import * as actions from './index'; -import * as messages from '../../shared/messages'; const startSet = (): actions.MarkAction => { return { type: actions.MARK_START_SET }; @@ -13,34 +12,6 @@ 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, + startSet, startJump, cancel, }; 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; + + jumpGlobalMark(key: string): Promise; + + // eslint-disable-next-line semi +} + +export class MarkClientImpl implements MarkClient { + async setGloablMark(key: string, mark: Mark): Promise { + await browser.runtime.sendMessage({ + type: messages.MARK_SET_GLOBAL, + key, + x: mark.x, + y: mark.y, + }); + } + + async jumpGlobalMark(key: string): Promise { + await browser.runtime.sendMessage({ + type: messages.MARK_JUMP_GLOBAL, + key, + }); + } +} diff --git a/src/content/components/common/mark.ts b/src/content/components/common/mark.ts index ddd1a38..eec95d6 100644 --- a/src/content/components/common/mark.ts +++ b/src/content/components/common/mark.ts @@ -1,22 +1,15 @@ import * as markActions from '../../actions/mark'; import * as consoleFrames from '../..//console-frames'; import * as keyUtils from '../../../shared/utils/keys'; -import Mark from '../../Mark'; -import { SettingRepositoryImpl } from '../../repositories/SettingRepository'; -import { ScrollPresenterImpl } from '../../presenters/ScrollPresenter'; +import MarkUseCase from '../../usecases/MarkUseCase'; -let settingRepository = new SettingRepositoryImpl(); -let scrollPresenter = new ScrollPresenterImpl(); +let markUseCase = new MarkUseCase(); 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; @@ -26,7 +19,6 @@ export default class MarkComponent { // eslint-disable-next-line max-statements key(key: keyUtils.Key) { - let smoothscroll = settingRepository.get().properties.smoothscroll; let { mark: markState } = this.store.getState(); if (!markState.setMode && !markState.jumpMode) { @@ -40,45 +32,13 @@ export default class MarkComponent { 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); + markUseCase.set(key.key); } else if (markState.jumpMode) { - this.doJump(markState.marks, key, smoothscroll); + markUseCase.jump(key.key); } this.store.dispatch(markActions.cancel()); return true; } - - doSet(key: keyUtils.Key) { - let { x, y } = scrollPresenter.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]; - scrollPresenter.scrollTo(x, y, smoothscroll); - } - - doSetGlobal(key: keyUtils.Key) { - let { x, y } = scrollPresenter.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/domains/Mark.ts b/src/content/domains/Mark.ts new file mode 100644 index 0000000..f1282fc --- /dev/null +++ b/src/content/domains/Mark.ts @@ -0,0 +1,6 @@ +export default interface Mark { + x: number; + y: number; + // eslint-disable-next-line semi +} + diff --git a/src/content/presenters/ScrollPresenter.ts b/src/content/presenters/ScrollPresenter.ts index 9f47394..9286fb0 100644 --- a/src/content/presenters/ScrollPresenter.ts +++ b/src/content/presenters/ScrollPresenter.ts @@ -94,7 +94,7 @@ class Scroller { } } -type Point = { x: number, y: number }; +export type Point = { x: number, y: number }; export default interface ScrollPresenter { getScroll(): Point; diff --git a/src/content/reducers/mark.ts b/src/content/reducers/mark.ts index 7409938..a8f2f1b 100644 --- a/src/content/reducers/mark.ts +++ b/src/content/reducers/mark.ts @@ -1,16 +1,13 @@ -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( @@ -24,11 +21,6 @@ export default function reducer( 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/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/usecases/MarkUseCase.ts b/src/content/usecases/MarkUseCase.ts new file mode 100644 index 0000000..ec63f2b --- /dev/null +++ b/src/content/usecases/MarkUseCase.ts @@ -0,0 +1,62 @@ +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 { + 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 { + 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'); + } + let smooth = this.settingRepository.get().properties.smoothscroll; + this.scrollPresenter.scrollTo(pos.x, pos.y, smooth); + } + } + + private globalKey(key: string) { + return (/^[A-Z0-9]$/).test(key); + } +} diff --git a/test/content/actions/mark.test.ts b/test/content/actions/mark.test.ts index 6c6d59e..f2df367 100644 --- a/test/content/actions/mark.test.ts +++ b/test/content/actions/mark.test.ts @@ -22,14 +22,4 @@ describe('mark actions', () => { 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/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 { + this.isError = false; + this.text = text; + return Promise.resolve(); + } + + error(text: string): Promise { + 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/reducers/mark.test.ts b/test/content/reducers/mark.test.ts index 1a51c3e..918a560 100644 --- a/test/content/reducers/mark.test.ts +++ b/test/content/reducers/mark.test.ts @@ -6,7 +6,6 @@ describe("mark reducer", () => { 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', () => { @@ -29,13 +28,4 @@ describe("mark reducer", () => { 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/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/usecases/FindUseCase.test.ts b/test/content/usecases/FindUseCase.test.ts index 2f966ae..c7bfd39 100644 --- a/test/content/usecases/FindUseCase.test.ts +++ b/test/content/usecases/FindUseCase.test.ts @@ -1,8 +1,8 @@ import FindRepository from '../../../src/content/repositories/FindRepository'; import FindPresenter from '../../../src/content/presenters/FindPresenter'; -import ConsoleClient from '../../../src/content/client/ConsoleClient'; 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 { @@ -59,29 +59,6 @@ class MockFindClient implements FindClient { } } -class MockConsoleClient implements ConsoleClient { - public isError: boolean; - - public text: string; - - constructor() { - this.isError = false; - this.text = ''; - } - - info(text: string): Promise { - this.isError = false; - this.text = text; - return Promise.resolve(); - } - - error(text: string): Promise { - this.isError = true; - this.text = text; - return Promise.resolve(); - } -} - describe('FindUseCase', () => { let repository: MockFindRepository; let presenter: MockFindPresenter; 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 { + this.marks[key] = mark; + return Promise.resolve(); + } + + jumpGlobalMark(key: string): Promise { + 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') + }); + }); +}); -- cgit v1.2.3 From fb8b4d28ce47171a83a7bf5148293fd2318cc02f Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Thu, 16 May 2019 21:35:58 +0900 Subject: Hints as a classes --- src/content/client/TabsClient.ts | 12 ++- src/content/components/common/follow.ts | 79 +++++--------- src/content/components/common/hint.ts | 62 ----------- src/content/presenters/Hint.ts | 127 ++++++++++++++++++++++ test/content/components/common/hint.html | 1 - test/content/components/common/hint.test.ts | 57 ---------- test/content/presenters/Hint.test.html | 1 + test/content/presenters/Hint.test.ts | 158 ++++++++++++++++++++++++++++ 8 files changed, 319 insertions(+), 178 deletions(-) delete mode 100644 src/content/components/common/hint.ts create mode 100644 src/content/presenters/Hint.ts delete mode 100644 test/content/components/common/hint.html delete mode 100644 test/content/components/common/hint.test.ts create mode 100644 test/content/presenters/Hint.test.html create mode 100644 test/content/presenters/Hint.test.ts (limited to 'src/content/presenters') diff --git a/src/content/client/TabsClient.ts b/src/content/client/TabsClient.ts index fe72e11..e1af078 100644 --- a/src/content/client/TabsClient.ts +++ b/src/content/client/TabsClient.ts @@ -1,18 +1,22 @@ import * as messages from '../../shared/messages'; export default interface TabsClient { - openUrl(url: string, newTab: boolean): Promise; + openUrl(url: string, newTab: boolean, background?: boolean): Promise; // eslint-disable-next-line semi } -export class TabsClientImpl { - async openUrl(url: string, newTab: boolean): Promise { +export class TabsClientImpl implements TabsClient { + async openUrl( + url: string, + newTab: boolean, + background?: boolean, + ): Promise { 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 index 67f2dd9..a30a3d5 100644 --- a/src/content/components/common/follow.ts +++ b/src/content/components/common/follow.ts @@ -1,8 +1,11 @@ import MessageListener from '../../MessageListener'; -import Hint from './hint'; +import Hint, { LinkHint, InputHint } from '../../presenters/Hint'; import * as dom from '../../../shared/utils/dom'; import * as messages from '../../../shared/messages'; import * as keyUtils from '../../../shared/utils/keys'; +import TabsClient, { TabsClientImpl } from '../../client/TabsClient'; + +let tabsClient: TabsClient = new TabsClientImpl(); const TARGET_SELECTOR = [ 'a', 'button', 'input', 'textarea', 'area', @@ -95,27 +98,6 @@ export default class Follow { 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({ @@ -134,8 +116,13 @@ export default class Follow { 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; + let target = this.targets[i]; + if (target instanceof HTMLAnchorElement || + target instanceof HTMLAreaElement) { + this.hints[keys] = new LinkHint(target, keys); + } else { + this.hints[keys] = new InputHint(target, keys); + } } } @@ -154,42 +141,26 @@ export default class Follow { this.targets = []; } - activateHints(keys: string) { + async activateHints(keys: string): Promise { 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(); + + if (hint instanceof LinkHint) { + let url = hint.getLink(); + // ignore taget='_blank' + if (!this.newTab && hint.getLinkTarget() !== '_blank') { + hint.click(); + return; } - 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(); + // eslint-disable-next-line no-script-url + if (!url || url === '#' || url.toLowerCase().startsWith('javascript:')) { + return; } + await tabsClient.openUrl(url, this.newTab, this.background); + } else if (hint instanceof InputHint) { + hint.activate(); } } 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/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/test/content/components/common/hint.html b/test/content/components/common/hint.html deleted file mode 100644 index b50c5fe..0000000 --- a/test/content/components/common/hint.html +++ /dev/null @@ -1 +0,0 @@ -link 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/presenters/Hint.test.html b/test/content/presenters/Hint.test.html new file mode 100644 index 0000000..b50c5fe --- /dev/null +++ b/test/content/presenters/Hint.test.html @@ -0,0 +1 @@ +link 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 = `link`; + }); + + 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 = ` +link +link +link +`; + }); + + 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('', () => { + beforeEach(() => { + document.body.innerHTML = ``; + }); + + 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('', () => { + beforeEach(() => { + document.body.innerHTML = ``; + }); + + 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('`; + }); + + 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('`; + }); + + it('clicks the button', (done) => { + let button = document.getElementById('test-button') as HTMLButtonElement; + button.onclick = () => { done() }; + + let hint = new InputHint(button, 'abc'); + hint.activate(); + }); + }); + }); +}); -- cgit v1.2.3 From a88324acd9fe626b59637541975abe1ee6041aa7 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 18 May 2019 13:06:37 +0900 Subject: Define client and presenter for follow --- src/content/MessageListener.ts | 6 +- src/content/actions/operation.ts | 8 +- src/content/client/FollowMasterClient.ts | 47 ++++++ src/content/client/FollowSlaveClient.ts | 76 ++++++++++ src/content/components/common/follow.ts | 163 +++++---------------- src/content/components/common/index.ts | 2 +- .../components/top-content/follow-controller.ts | 95 ++++++------ src/content/presenters/FollowPresenter.ts | 134 +++++++++++++++++ src/shared/messages.ts | 8 +- 9 files changed, 359 insertions(+), 180 deletions(-) create mode 100644 src/content/client/FollowMasterClient.ts create mode 100644 src/content/client/FollowSlaveClient.ts create mode 100644 src/content/presenters/FollowPresenter.ts (limited to 'src/content/presenters') diff --git a/src/content/MessageListener.ts b/src/content/MessageListener.ts index 1d7a479..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); diff --git a/src/content/actions/operation.ts b/src/content/actions/operation.ts index 28192d7..657cf47 100644 --- a/src/content/actions/operation.ts +++ b/src/content/actions/operation.ts @@ -9,11 +9,13 @@ import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; import ClipboardUseCase from '../usecases/ClipboardUseCase'; import { SettingRepositoryImpl } from '../repositories/SettingRepository'; import { ScrollPresenterImpl } from '../presenters/ScrollPresenter'; +import { FollowMasterClientImpl } from '../client/FollowMasterClient'; let addonEnabledUseCase = new AddonEnabledUseCase(); let clipbaordUseCase = new ClipboardUseCase(); let settingRepository = new SettingRepositoryImpl(); let scrollPresenter = new ScrollPresenterImpl(); +let followMasterClient = new FollowMasterClientImpl(window.top); // eslint-disable-next-line complexity, max-lines-per-function const exec = async( @@ -63,11 +65,7 @@ const exec = async( scrollPresenter.scrollToEnd(smoothscroll); break; case operations.FOLLOW_START: - window.top.postMessage(JSON.stringify({ - type: messages.FOLLOW_START, - newTab: operation.newTab, - background: operation.background, - }), '*'); + followMasterClient.startFollow(operation.newTab, operation.background); break; case operations.MARK_SET_PREFIX: return markActions.startSet(); diff --git a/src/content/client/FollowMasterClient.ts b/src/content/client/FollowMasterClient.ts new file mode 100644 index 0000000..464b52f --- /dev/null +++ b/src/content/client/FollowMasterClient.ts @@ -0,0 +1,47 @@ +import * as messages from '../../shared/messages'; +import { Key } from '../../shared/utils/keys'; + +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/components/common/follow.ts b/src/content/components/common/follow.ts index 9a62613..e0003e3 100644 --- a/src/content/components/common/follow.ts +++ b/src/content/components/common/follow.ts @@ -1,17 +1,18 @@ import MessageListener from '../../MessageListener'; -import Hint, { LinkHint, InputHint } from '../../presenters/Hint'; -import * as dom from '../../../shared/utils/dom'; +import { LinkHint, InputHint } from '../../presenters/Hint'; import * as messages from '../../../shared/messages'; -import * as keyUtils from '../../../shared/utils/keys'; +import { Key } from '../../../shared/utils/keys'; import TabsClient, { TabsClientImpl } from '../../client/TabsClient'; +import FollowMasterClient, { FollowMasterClientImpl } + from '../../client/FollowMasterClient'; +import FollowPresenter, { FollowPresenterImpl } + from '../../presenters/FollowPresenter'; let tabsClient: TabsClient = new TabsClientImpl(); - -const TARGET_SELECTOR = [ - 'a', 'button', 'input', 'textarea', 'area', - '[contenteditable=true]', '[contenteditable=""]', '[tabindex]', - '[role="button"]', 'summary' -].join(','); +let followMasterClient: FollowMasterClient = + new FollowMasterClientImpl(window.top); +let followPresenter: FollowPresenter = + new FollowPresenterImpl(); interface Size { width: number; @@ -23,118 +24,46 @@ interface Point { 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 enabled: boolean; - private hints: {[key: string]: Hint }; - - private targets: HTMLElement[] = []; - - constructor(win: Window) { - this.win = win; - this.hints = {}; - this.targets = []; + constructor() { + this.enabled = false; new MessageListener().onWebMessage(this.onMessage.bind(this)); } - key(key: keyUtils.Key): boolean { - if (Object.keys(this.hints).length === 0) { + key(key: Key): boolean { + if (!this.enabled) { return false; } - this.win.parent.postMessage(JSON.stringify({ - type: messages.FOLLOW_KEY_PRESS, - key: key.key, - ctrlKey: key.ctrlKey, - }), '*'); + followMasterClient.sendKey(key); return true; } - 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, - }), '*'); + countHints(viewSize: Size, framePosition: Point) { + let count = followPresenter.getTargetCount(viewSize, framePosition); + followMasterClient.responseHintCount(count); } - createHints(keysArray: string[]) { - if (keysArray.length !== this.targets.length) { - throw new Error('illegal hint count'); - } - - this.hints = {}; - for (let i = 0; i < keysArray.length; ++i) { - let keys = keysArray[i]; - let target = this.targets[i]; - if (target instanceof HTMLAnchorElement || - target instanceof HTMLAreaElement) { - this.hints[keys] = new LinkHint(target, keys); - } else { - this.hints[keys] = new InputHint(target, keys); - } - } + createHints(viewSize: Size, framePosition: Point, tags: string[]) { + this.enabled = true; + followPresenter.createHints(viewSize, framePosition, tags); } - 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()); + showHints(prefix: string) { + followPresenter.filterHints(prefix); } removeHints() { - Object.keys(this.hints).forEach((key) => { - this.hints[key].remove(); - }); - this.hints = {}; - this.targets = []; + followPresenter.clearHints(); + this.enabled = false; } - async activateHints(keys: string, newTab: boolean, background: boolean): Promise { - let hint = this.hints[keys]; + async activateHints( + tag: string, newTab: boolean, background: boolean, + ): Promise { + let hint = followPresenter.getHint(tag); if (!hint) { return; } @@ -156,38 +85,20 @@ export default class Follow { } } - onMessage(message: messages.Message, sender: any) { + onMessage(message: messages.Message, _sender: Window) { switch (message.type) { case messages.FOLLOW_REQUEST_COUNT_TARGETS: - return this.countHints(sender, message.viewSize, message.framePosition); + return this.countHints(message.viewSize, message.framePosition); case messages.FOLLOW_CREATE_HINTS: - return this.createHints(message.keysArray); + return this.createHints( + message.viewSize, message.framePosition, message.tags); case messages.FOLLOW_SHOW_HINTS: - return this.showHints(message.keys); + return this.showHints(message.prefix); case messages.FOLLOW_ACTIVATE: - return this.activateHints(message.keys, message.newTab, message.background); + return this.activateHints( + message.tag, message.newTab, message.background); 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/index.ts b/src/content/components/common/index.ts index 899953d..b2f48a3 100644 --- a/src/content/components/common/index.ts +++ b/src/content/components/common/index.ts @@ -16,7 +16,7 @@ let settingUseCase = new SettingUseCase(); export default class Common { constructor(win: Window, store: any) { const input = new InputComponent(win.document.body); - const follow = new FollowComponent(win); + const follow = new FollowComponent(); const mark = new MarkComponent(store); const keymapper = new KeymapperComponent(store); diff --git a/src/content/components/top-content/follow-controller.ts b/src/content/components/top-content/follow-controller.ts index 2a242c2..43c917e 100644 --- a/src/content/components/top-content/follow-controller.ts +++ b/src/content/components/top-content/follow-controller.ts @@ -1,18 +1,14 @@ import * as followControllerActions from '../../actions/follow-controller'; import * as messages from '../../../shared/messages'; -import MessageListener, { WebMessageSender } from '../../MessageListener'; +import MessageListener from '../../MessageListener'; import HintKeyProducer from '../../hint-key-producer'; import { SettingRepositoryImpl } from '../../repositories/SettingRepository'; +import FollowSlaveClient, { FollowSlaveClientImpl } + from '../../client/FollowSlaveClient'; let settingRepository = new SettingRepositoryImpl(); -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; @@ -43,7 +39,7 @@ export default class FollowController { }); } - onMessage(message: messages.Message, sender: WebMessageSender) { + onMessage(message: messages.Message, sender: Window) { switch (message.type) { case messages.FOLLOW_START: return this.store.dispatch( @@ -77,18 +73,17 @@ export default class FollowController { this.store.dispatch(followControllerActions.disable()); } - broadcastMessage(this.win, { - type: messages.FOLLOW_SHOW_HINTS, - keys: this.state.keys as string, + this.broadcastMessage((c: FollowSlaveClient) => { + c.filterHints(this.state.keys!!); }); } activate(): void { - broadcastMessage(this.win, { - type: messages.FOLLOW_ACTIVATE, - keys: this.state.keys as string, - newTab: this.state.newTab!!, - background: this.state.background!!, + this.broadcastMessage((c: FollowSlaveClient) => { + c.activateIfExists( + this.state.keys!!, + this.state.newTab!!, + this.state.background!!); }); } @@ -123,50 +118,64 @@ export default class FollowController { 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 frameElements = this.win.document.querySelectorAll('iframe'); + + new FollowSlaveClientImpl(this.win).requestHintCount( + { width: viewWidth, height: viewHeight }, + { x: 0, y: 0 }); + + for (let ele of Array.from(frameElements)) { 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, '*'); - } - }); + new FollowSlaveClientImpl(ele.contentWindow!!).requestHintCount( + { width: viewWidth, height: viewHeight }, + { x: frameX, y: frameY }, + ); + } } - create(count: number, sender: WebMessageSender) { + create(count: number, sender: Window) { 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, - }), '*'); + let doc = this.win.document; + let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; + let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; + let pos = { x: 0, y: 0 }; + if (sender !== window) { + let frameElements = this.win.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, + ); } remove() { this.keys = []; - broadcastMessage(this.win, { - type: messages.FOLLOW_REMOVE_HINTS, + this.broadcastMessage((c: FollowSlaveClient) => { + c.clearHints(); }); } private hintchars() { return settingRepository.get().properties.hintchars; } + + private broadcastMessage(f: (clinet: FollowSlaveClient) => void) { + let windows = [window.self].concat(Array.from(window.frames as any)); + windows + .map(w => new FollowSlaveClientImpl(w)) + .forEach(c => f(c)); + } } 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/shared/messages.ts b/src/shared/messages.ts index 816eba2..fbd3478 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -108,12 +108,14 @@ export interface FollowResponseCountTargetsMessage { export interface FollowCreateHintsMessage { type: typeof FOLLOW_CREATE_HINTS; - keysArray: string[]; + tags: string[]; + viewSize: { width: number, height: number }; + framePosition: { x: number, y: number }; } export interface FollowShowHintsMessage { type: typeof FOLLOW_SHOW_HINTS; - keys: string; + prefix: string; } export interface FollowRemoveHintsMessage { @@ -122,7 +124,7 @@ export interface FollowRemoveHintsMessage { export interface FollowActivateMessage { type: typeof FOLLOW_ACTIVATE; - keys: string; + tag: string; newTab: boolean; background: boolean; } -- cgit v1.2.3 From efc48dc7421e3bd48534bc94f84e2b0bd47ae47c Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sat, 11 May 2019 19:43:56 +0900 Subject: Keymaps as a clean architecture [WIP] --- src/content/InputDriver.ts | 80 +++++++++++++++ src/content/client/BackgroundClient.ts | 11 +++ src/content/client/FindMasterClient.ts | 23 +++++ src/content/components/common/index.ts | 4 +- src/content/components/common/input.ts | 80 --------------- src/content/controllers/KeymapController.ts | 139 +++++++++++++++++++++++++++ src/content/index.ts | 46 +++++++-- src/content/presenters/FocusPresenter.ts | 25 +++++ src/content/repositories/KeymapRepository.ts | 23 +++++ src/content/usecases/FindSlaveUseCase.ts | 20 ++++ src/content/usecases/FocusUseCase.ts | 15 +++ src/content/usecases/KeymapUseCase.ts | 100 +++++++++++++++++++ src/content/usecases/NavigateUseCase.ts | 27 ++++++ src/content/usecases/ScrollUseCase.ts | 58 +++++++++++ test/content/InputDriver.test.ts | 129 +++++++++++++++++++++++++ test/content/components/common/input.test.ts | 72 -------------- 16 files changed, 692 insertions(+), 160 deletions(-) create mode 100644 src/content/InputDriver.ts create mode 100644 src/content/client/BackgroundClient.ts create mode 100644 src/content/client/FindMasterClient.ts delete mode 100644 src/content/components/common/input.ts create mode 100644 src/content/controllers/KeymapController.ts create mode 100644 src/content/presenters/FocusPresenter.ts create mode 100644 src/content/repositories/KeymapRepository.ts create mode 100644 src/content/usecases/FindSlaveUseCase.ts create mode 100644 src/content/usecases/FocusUseCase.ts create mode 100644 src/content/usecases/KeymapUseCase.ts create mode 100644 src/content/usecases/NavigateUseCase.ts create mode 100644 src/content/usecases/ScrollUseCase.ts create mode 100644 test/content/InputDriver.test.ts delete mode 100644 test/content/components/common/input.test.ts (limited to 'src/content/presenters') diff --git a/src/content/InputDriver.ts b/src/content/InputDriver.ts new file mode 100644 index 0000000..09648c1 --- /dev/null +++ b/src/content/InputDriver.ts @@ -0,0 +1,80 @@ +import * as dom from '../shared/utils/dom'; +import * as keys from '../shared/utils/keys'; + +const cancelKey = (e: KeyboardEvent): boolean => { + return e.key === 'Escape' || e.key === '[' && e.ctrlKey; +}; + +export default class InputDriver { + private pressed: {[key: string]: string} = {}; + + private onKeyListeners: ((key: keys.Key) => boolean)[] = []; + + constructor(target: HTMLElement) { + this.pressed = {}; + this.onKeyListeners = []; + + target.addEventListener('keypress', this.onKeyPress.bind(this)); + target.addEventListener('keydown', this.onKeyDown.bind(this)); + target.addEventListener('keyup', this.onKeyUp.bind(this)); + } + + onKey(cb: (key: keys.Key) => boolean) { + this.onKeyListeners.push(cb); + } + + private onKeyPress(e: KeyboardEvent) { + if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') { + return; + } + this.pressed[e.key] = 'keypress'; + this.capture(e); + } + + private onKeyDown(e: KeyboardEvent) { + if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') { + return; + } + this.pressed[e.key] = 'keydown'; + this.capture(e); + } + + private onKeyUp(e: KeyboardEvent) { + delete this.pressed[e.key]; + } + + // eslint-disable-next-line max-statements + private capture(e: KeyboardEvent) { + let target = e.target; + if (!(target instanceof HTMLElement)) { + return; + } + if (this.fromInput(target)) { + if (cancelKey(e) && target.blur) { + target.blur(); + } + return; + } + if (['Shift', 'Control', 'Alt', 'OS'].includes(e.key)) { + // pressing only meta key is ignored + return; + } + + let key = keys.fromKeyboardEvent(e); + for (let listener of this.onKeyListeners) { + let stop = listener(key); + if (stop) { + e.preventDefault(); + e.stopPropagation(); + break; + } + } + } + + private fromInput(e: Element) { + return e instanceof HTMLInputElement || + e instanceof HTMLTextAreaElement || + e instanceof HTMLSelectElement || + dom.isContentEditable(e); + } +} 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 { + return browser.runtime.sendMessage({ + type: messages.BACKGROUND_OPERATION, + operation: op, + }); + } +} diff --git a/src/content/client/FindMasterClient.ts b/src/content/client/FindMasterClient.ts new file mode 100644 index 0000000..0481ec1 --- /dev/null +++ b/src/content/client/FindMasterClient.ts @@ -0,0 +1,23 @@ +import * as messages from '../../shared/messages'; + +export default interface FindMasterClient { + findNext(): void; + + findPrev(): void; + + // eslint-disable-next-line semi +} + +export class FindMasterClientImpl implements FindMasterClient { + findNext(): void { + window.top.postMessage(JSON.stringify({ + type: messages.FIND_NEXT, + }), '*'); + } + + findPrev(): void { + window.top.postMessage(JSON.stringify({ + type: messages.FIND_PREV, + }), '*'); + } +} diff --git a/src/content/components/common/index.ts b/src/content/components/common/index.ts index b2f48a3..c74020e 100644 --- a/src/content/components/common/index.ts +++ b/src/content/components/common/index.ts @@ -1,4 +1,4 @@ -import InputComponent from './input'; +import InputDriver from './../../InputDriver'; import FollowComponent from './follow'; import MarkComponent from './mark'; import KeymapperComponent from './keymapper'; @@ -15,7 +15,7 @@ let settingUseCase = new SettingUseCase(); export default class Common { constructor(win: Window, store: any) { - const input = new InputComponent(win.document.body); + const input = new InputDriver(win.document.body); const follow = new FollowComponent(); const mark = new MarkComponent(store); const keymapper = new KeymapperComponent(store); diff --git a/src/content/components/common/input.ts b/src/content/components/common/input.ts deleted file mode 100644 index 1fe34c9..0000000 --- a/src/content/components/common/input.ts +++ /dev/null @@ -1,80 +0,0 @@ -import * as dom from '../../../shared/utils/dom'; -import * as keys from '../../../shared/utils/keys'; - -const cancelKey = (e: KeyboardEvent): boolean => { - return e.key === 'Escape' || e.key === '[' && e.ctrlKey; -}; - -export default class InputComponent { - private pressed: {[key: string]: string} = {}; - - private onKeyListeners: ((key: keys.Key) => boolean)[] = []; - - constructor(target: HTMLElement) { - this.pressed = {}; - this.onKeyListeners = []; - - target.addEventListener('keypress', this.onKeyPress.bind(this)); - target.addEventListener('keydown', this.onKeyDown.bind(this)); - target.addEventListener('keyup', this.onKeyUp.bind(this)); - } - - onKey(cb: (key: keys.Key) => boolean) { - this.onKeyListeners.push(cb); - } - - onKeyPress(e: KeyboardEvent) { - if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') { - return; - } - this.pressed[e.key] = 'keypress'; - this.capture(e); - } - - onKeyDown(e: KeyboardEvent) { - if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') { - return; - } - this.pressed[e.key] = 'keydown'; - this.capture(e); - } - - onKeyUp(e: KeyboardEvent) { - delete this.pressed[e.key]; - } - - // eslint-disable-next-line max-statements - capture(e: KeyboardEvent) { - let target = e.target; - if (!(target instanceof HTMLElement)) { - return; - } - if (this.fromInput(target)) { - if (cancelKey(e) && target.blur) { - target.blur(); - } - return; - } - if (['Shift', 'Control', 'Alt', 'OS'].includes(e.key)) { - // pressing only meta key is ignored - return; - } - - let key = keys.fromKeyboardEvent(e); - for (let listener of this.onKeyListeners) { - let stop = listener(key); - if (stop) { - e.preventDefault(); - e.stopPropagation(); - break; - } - } - } - - fromInput(e: Element) { - return e instanceof HTMLInputElement || - e instanceof HTMLTextAreaElement || - e instanceof HTMLSelectElement || - dom.isContentEditable(e); - } -} diff --git a/src/content/controllers/KeymapController.ts b/src/content/controllers/KeymapController.ts new file mode 100644 index 0000000..09e5b0c --- /dev/null +++ b/src/content/controllers/KeymapController.ts @@ -0,0 +1,139 @@ +import * as operations from '../../shared/operations'; +import KeymapUseCase from '../usecases/KeymapUseCase'; +import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase'; +import FindSlaveUseCase from '../usecases/FindSlaveUseCase'; +import ScrollUseCase from '../usecases/ScrollUseCase'; +import NavigateUseCase from '../usecases/NavigateUseCase'; +import FocusUseCase from '../usecases/FocusUseCase'; +import ClipboardUseCase from '../usecases/ClipboardUseCase'; +import BackgroundClient from '../client/BackgroundClient'; +import { Key } from '../../shared/utils/keys'; + +export default class KeymapController { + private keymapUseCase: KeymapUseCase; + + private addonEnabledUseCase: AddonEnabledUseCase; + + private findSlaveUseCase: FindSlaveUseCase; + + private scrollUseCase: ScrollUseCase; + + private navigateUseCase: NavigateUseCase; + + private focusUseCase: FocusUseCase; + + private clipbaordUseCase: ClipboardUseCase; + + private backgroundClient: BackgroundClient; + + constructor({ + keymapUseCase = new KeymapUseCase(), + addonEnabledUseCase = new AddonEnabledUseCase(), + findSlaveUseCase = new FindSlaveUseCase(), + scrollUseCase = new ScrollUseCase(), + navigateUseCase = new NavigateUseCase(), + focusUseCase = new FocusUseCase(), + clipbaordUseCase = new ClipboardUseCase(), + backgroundClient = new BackgroundClient(), + } = {}) { + this.keymapUseCase = keymapUseCase; + this.addonEnabledUseCase = addonEnabledUseCase; + this.findSlaveUseCase = findSlaveUseCase; + this.scrollUseCase = scrollUseCase; + this.navigateUseCase = navigateUseCase; + this.focusUseCase = focusUseCase; + this.clipbaordUseCase = clipbaordUseCase; + this.backgroundClient = backgroundClient; + } + + // eslint-disable-next-line complexity, max-lines-per-function + press(key: Key): boolean { + let op = this.keymapUseCase.nextOp(key); + if (op === null) { + return false; + } + + // do not await due to return a boolean immediately + switch (op.type) { + case operations.ADDON_ENABLE: + this.addonEnabledUseCase.enable(); + break; + case operations.ADDON_DISABLE: + this.addonEnabledUseCase.disable(); + break; + case operations.ADDON_TOGGLE_ENABLED: + this.addonEnabledUseCase.toggle(); + break; + case operations.FIND_NEXT: + this.findSlaveUseCase.findNext(); + break; + case operations.FIND_PREV: + this.findSlaveUseCase.findPrev(); + break; + case operations.SCROLL_VERTICALLY: + this.scrollUseCase.scrollVertically(op.count); + break; + case operations.SCROLL_HORIZONALLY: + this.scrollUseCase.scrollHorizonally(op.count); + break; + case operations.SCROLL_PAGES: + this.scrollUseCase.scrollPages(op.count); + break; + case operations.SCROLL_TOP: + this.scrollUseCase.scrollToTop(); + break; + case operations.SCROLL_BOTTOM: + this.scrollUseCase.scrollToBottom(); + break; + case operations.SCROLL_HOME: + this.scrollUseCase.scrollToHome(); + break; + case operations.SCROLL_END: + this.scrollUseCase.scrollToEnd(); + break; + // case operations.FOLLOW_START: + // window.top.postMessage(JSON.stringify({ + // type: messages.FOLLOW_START, + // newTab: operation.newTab, + // background: operation.background, + // }), '*'); + // break; + // case operations.MARK_SET_PREFIX: + // return markActions.startSet(); + // case operations.MARK_JUMP_PREFIX: + // return markActions.startJump(); + case operations.NAVIGATE_HISTORY_PREV: + this.navigateUseCase.openHistoryPrev(); + break; + case operations.NAVIGATE_HISTORY_NEXT: + this.navigateUseCase.openHistoryNext(); + break; + case operations.NAVIGATE_LINK_PREV: + this.navigateUseCase.openLinkPrev(); + break; + case operations.NAVIGATE_LINK_NEXT: + this.navigateUseCase.openLinkNext(); + break; + case operations.NAVIGATE_PARENT: + this.navigateUseCase.openParent(); + break; + case operations.NAVIGATE_ROOT: + this.navigateUseCase.openRoot(); + break; + case operations.FOCUS_INPUT: + this.focusUseCase.focusFirstInput(); + break; + case operations.URLS_YANK: + this.clipbaordUseCase.yankCurrentURL(); + break; + case operations.URLS_PASTE: + this.clipbaordUseCase.openOrSearch( + op.newTab ? op.newTab : false, + ); + break; + default: + this.backgroundClient.execBackgroundOp(op); + } + return true; + } +} diff --git a/src/content/index.ts b/src/content/index.ts index 4024b98..f983f9f 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,15 +1,20 @@ -import TopContentComponent from './components/top-content'; -import FrameContentComponent from './components/frame-content'; +// import TopContentComponent from './components/top-content'; +// import FrameContentComponent from './components/frame-content'; import consoleFrameStyle from './site-style'; -import { newStore } from './store'; +// import { newStore } from './store'; import MessageListener from './MessageListener'; import FindController from './controllers/FindController'; import * as messages from '../shared/messages'; +import InputDriver from './InputDriver'; +import KeymapController from './controllers/KeymapController'; +import AddonEnabledUseCase from './usecases/AddonEnabledUseCase'; +import SettingUseCase from './usecases/SettingUseCase'; +import * as blacklists from '../shared/blacklists'; -const store = newStore(); +// const store = newStore(); if (window.self === window.top) { - new TopContentComponent(window, store); // eslint-disable-line no-new + // new TopContentComponent(window, store); // eslint-disable-line no-new let findController = new FindController(); new MessageListener().onWebMessage((message: messages.Message) => { @@ -24,9 +29,38 @@ if (window.self === window.top) { return undefined; }); } else { - new FrameContentComponent(window, store); // eslint-disable-line no-new + // new FrameContentComponent(window, store); // eslint-disable-line no-new } +let keymapController = new KeymapController(); +let inputDriver = new InputDriver(document.body); +// inputDriver.onKey(key => followSlaveController.pressKey(key)); +// inputDriver.onKey(key => markController.pressKey(key)); +inputDriver.onKey(key => keymapController.press(key)); + let style = window.document.createElement('style'); style.textContent = consoleFrameStyle; window.document.head.appendChild(style); + +// TODO move the following to a class +const reloadSettings = async() => { + let addonEnabledUseCase = new AddonEnabledUseCase(); + let settingUseCase = new SettingUseCase(); + + try { + let current = await settingUseCase.reload(); + let disabled = blacklists.includes( + current.blacklist, window.location.href, + ); + if (disabled) { + addonEnabledUseCase.disable(); + } else { + addonEnabledUseCase.enable(); + } + } catch (e) { + // Sometime sendMessage fails when background script is not ready. + console.warn(e); + setTimeout(() => reloadSettings(), 500); + } +}; +reloadSettings(); diff --git a/src/content/presenters/FocusPresenter.ts b/src/content/presenters/FocusPresenter.ts new file mode 100644 index 0000000..4cef5bf --- /dev/null +++ b/src/content/presenters/FocusPresenter.ts @@ -0,0 +1,25 @@ +import * as doms from '../../shared/utils/dom'; + +export default interface FocusPresenter { + focusFirstElement(): boolean; + + // eslint-disable-next-line semi +} + +export class FocusPresenterImpl implements FocusPresenter { + focusFirstElement(): boolean { + let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url']; + let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(','); + let targets = window.document.querySelectorAll(inputSelector + ',textarea'); + let target = Array.from(targets).find(doms.isVisible); + if (target instanceof HTMLInputElement) { + target.focus(); + return true; + } else if (target instanceof HTMLTextAreaElement) { + target.focus(); + return true; + } + return false; + } +} + diff --git a/src/content/repositories/KeymapRepository.ts b/src/content/repositories/KeymapRepository.ts new file mode 100644 index 0000000..081cc54 --- /dev/null +++ b/src/content/repositories/KeymapRepository.ts @@ -0,0 +1,23 @@ +import { Key } from '../../shared/utils/keys'; + +export default interface KeymapRepository { + enqueueKey(key: Key): Key[]; + + clear(): void; + + // eslint-disable-next-line semi +} + +let current: Key[] = []; + +export class KeymapRepositoryImpl { + + enqueueKey(key: Key): Key[] { + current.push(key); + return current; + } + + clear(): void { + current = []; + } +} diff --git a/src/content/usecases/FindSlaveUseCase.ts b/src/content/usecases/FindSlaveUseCase.ts new file mode 100644 index 0000000..b733cbd --- /dev/null +++ b/src/content/usecases/FindSlaveUseCase.ts @@ -0,0 +1,20 @@ +import FindMasterClient, { FindMasterClientImpl } + from '../client/FindMasterClient'; + +export default class FindSlaveUseCase { + private findMasterClient: FindMasterClient; + + constructor({ + findMasterClient = new FindMasterClientImpl(), + } = {}) { + this.findMasterClient = findMasterClient; + } + + findNext() { + this.findMasterClient.findNext(); + } + + findPrev() { + this.findMasterClient.findPrev(); + } +} diff --git a/src/content/usecases/FocusUseCase.ts b/src/content/usecases/FocusUseCase.ts new file mode 100644 index 0000000..615442d --- /dev/null +++ b/src/content/usecases/FocusUseCase.ts @@ -0,0 +1,15 @@ +import FocusPresenter, { FocusPresenterImpl } + from '../presenters/FocusPresenter'; +export default class FocusUseCases { + private presenter: FocusPresenter; + + constructor({ + presenter = new FocusPresenterImpl(), + } = {}) { + this.presenter = presenter; + } + + focusFirstInput() { + this.presenter.focusFirstElement(); + } +} diff --git a/src/content/usecases/KeymapUseCase.ts b/src/content/usecases/KeymapUseCase.ts new file mode 100644 index 0000000..a4f9c36 --- /dev/null +++ b/src/content/usecases/KeymapUseCase.ts @@ -0,0 +1,100 @@ +import KeymapRepository, { KeymapRepositoryImpl } + from '../repositories/KeymapRepository'; +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; +import AddonEnabledRepository, { AddonEnabledRepositoryImpl } + from '../repositories/AddonEnabledRepository'; + +import * as operations from '../../shared/operations'; +import { Keymaps } from '../../shared/Settings'; +import * as keyUtils from '../../shared/utils/keys'; + +type KeymapEntityMap = Map; + +const reservedKeymaps: Keymaps = { + '': { type: operations.CANCEL }, + '': { type: operations.CANCEL }, +}; + +const mapStartsWith = ( + mapping: keyUtils.Key[], + keys: keyUtils.Key[], +): boolean => { + if (mapping.length < keys.length) { + return false; + } + for (let i = 0; i < keys.length; ++i) { + if (!keyUtils.equals(mapping[i], keys[i])) { + return false; + } + } + return true; +}; + +export default class KeymapUseCase { + private repository: KeymapRepository; + + private settingRepository: SettingRepository; + + private addonEnabledRepository: AddonEnabledRepository; + + constructor({ + repository = new KeymapRepositoryImpl(), + settingRepository = new SettingRepositoryImpl(), + addonEnabledRepository = new AddonEnabledRepositoryImpl(), + } = {}) { + this.repository = repository; + this.settingRepository = settingRepository; + this.addonEnabledRepository = addonEnabledRepository; + } + + nextOp(key: keyUtils.Key): operations.Operation | null { + let keys = this.repository.enqueueKey(key); + + let keymaps = this.keymapEntityMap(); + let matched = Array.from(keymaps.keys()).filter( + (mapping: keyUtils.Key[]) => { + return mapStartsWith(mapping, keys); + }); + if (!this.addonEnabledRepository.get()) { + // available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if + // the addon disabled + matched = matched.filter((keymap) => { + let type = (keymaps.get(keymap) as operations.Operation).type; + return type === operations.ADDON_ENABLE || + type === operations.ADDON_TOGGLE_ENABLED; + }); + } + if (matched.length === 0) { + // No operations to match with inputs + this.repository.clear(); + return null; + } else if (matched.length > 1 || + matched.length === 1 && keys.length < matched[0].length) { + // More than one operations are matched + return null; + } + // Exactly one operation is matched + let operation = keymaps.get(matched[0]) as operations.Operation; + this.repository.clear(); + return operation; + } + + clear(): void { + this.repository.clear(); + } + + private keymapEntityMap(): KeymapEntityMap { + let keymaps = { + ...this.settingRepository.get().keymaps, + ...reservedKeymaps, + }; + let entries = Object.entries(keymaps).map((entry) => { + return [ + keyUtils.fromMapKeys(entry[0]), + entry[1], + ]; + }) as [keyUtils.Key[], operations.Operation][]; + return new Map(entries); + } +} diff --git a/src/content/usecases/NavigateUseCase.ts b/src/content/usecases/NavigateUseCase.ts new file mode 100644 index 0000000..f790212 --- /dev/null +++ b/src/content/usecases/NavigateUseCase.ts @@ -0,0 +1,27 @@ +import * as navigates from '../navigates'; + +export default class NavigateClass { + openHistoryPrev(): void { + navigates.historyPrev(window); + } + + openHistoryNext(): void { + navigates.historyNext(window); + } + + openLinkPrev(): void { + navigates.linkPrev(window); + } + + openLinkNext(): void { + navigates.linkNext(window); + } + + openParent(): void { + navigates.parent(window); + } + + openRoot(): void { + navigates.root(window); + } +} diff --git a/src/content/usecases/ScrollUseCase.ts b/src/content/usecases/ScrollUseCase.ts new file mode 100644 index 0000000..6a1f801 --- /dev/null +++ b/src/content/usecases/ScrollUseCase.ts @@ -0,0 +1,58 @@ +import ScrollPresenter, { ScrollPresenterImpl } + from '../presenters/ScrollPresenter'; +import SettingRepository, { SettingRepositoryImpl } + from '../repositories/SettingRepository'; + +export default class ScrollUseCase { + private presenter: ScrollPresenter; + + private settingRepository: SettingRepository; + + constructor({ + presenter = new ScrollPresenterImpl(), + settingRepository = new SettingRepositoryImpl(), + } = {}) { + this.presenter = presenter; + this.settingRepository = settingRepository; + } + + scrollVertically(count: number): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollVertically(count, smooth); + } + + scrollHorizonally(count: number): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollHorizonally(count, smooth); + } + + scrollPages(count: number): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollPages(count, smooth); + } + + scrollToTop(): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollToTop(smooth); + } + + scrollToBottom(): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollToBottom(smooth); + } + + scrollToHome(): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollToHome(smooth); + } + + scrollToEnd(): void { + let smooth = this.getSmoothScroll(); + this.presenter.scrollToEnd(smooth); + } + + private getSmoothScroll(): boolean { + let settings = this.settingRepository.get(); + return settings.properties.smoothscroll; + } +} diff --git a/test/content/InputDriver.test.ts b/test/content/InputDriver.test.ts new file mode 100644 index 0000000..ac5f95d --- /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/shared/utils/keys'; + +describe('InputDriver', () => { + let target: HTMLElement; + let driver: InputDriver; + + beforeEach(() => { + target = document.createElement('div'); + document.body.appendChild(target); + driver = new InputDriver(target); + }); + + afterEach(() => { + target.remove(); + target = null; + driver = null; + }); + + it('register callbacks', (done) => { + driver.onKey((key: Key): boolean => { + expect(key.key).to.equal('a'); + expect(key.ctrlKey).to.be.true; + expect(key.shiftKey).to.be.false; + expect(key.altKey).to.be.false; + expect(key.metaKey).to.be.false; + done(); + return true; + }); + + target.dispatchEvent(new KeyboardEvent('keydown', { + key: 'a', + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + })); + }); + + it('invoke callback once', () => { + let a = 0, b = 0; + driver.onKey((key: Key): boolean => { + if (key.key == 'a') { + ++a; + } else { + key.key == 'b' + ++b; + } + return true; + }); + + let events = [ + new KeyboardEvent('keydown', { key: 'a' }), + new KeyboardEvent('keydown', { key: 'b' }), + new KeyboardEvent('keypress', { key: 'a' }), + new KeyboardEvent('keyup', { key: 'a' }), + new KeyboardEvent('keypress', { key: 'b' }), + new KeyboardEvent('keyup', { key: 'b' }), + ]; + for (let e of events) { + target.dispatchEvent(e); + } + + expect(a).to.equal(1); + expect(b).to.equal(1); + }) + + it('propagates and stop handler chain', () => { + let a = 0, b = 0, c = 0; + driver.onKey((key: Key): boolean => { + a++; + return false; + }); + driver.onKey((key: Key): boolean => { + b++; + return true; + }); + driver.onKey((key: Key): boolean => { + c++; + return true; + }); + + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' })); + + expect(a).to.equal(1); + expect(b).to.equal(1); + expect(c).to.equal(0); + }) + + it('does not invoke only meta keys', () => { + driver.onKey((key: Key): boolean=> { + expect.fail(); + return false; + }); + + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' })); + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' })); + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })); + target.dispatchEvent(new KeyboardEvent('keydown', { key: 'OS' })); + }) + + it('ignores events from input elements', () => { + ['input', 'textarea', 'select'].forEach((name) => { + let input = window.document.createElement(name); + let driver = new InputDriver(input); + driver.onKey((key: Key): boolean => { + expect.fail(); + return false; + }); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + }); + }); + + it('ignores events from contenteditable elements', () => { + let div = window.document.createElement('div'); + let driver = new InputDriver(div); + driver.onKey((key: Key): boolean => { + expect.fail(); + return false; + }); + + div.setAttribute('contenteditable', ''); + div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + + div.setAttribute('contenteditable', 'true'); + div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' })); + }); +}); 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 }); - }) -}); -- cgit v1.2.3 From c81b82ee39cc05d06597e7cd024de448894efe43 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 19 May 2019 10:50:49 +0900 Subject: Make routes --- src/content/console-frames.ts | 16 --- src/content/controllers/ConsoleFrameController.ts | 16 +++ src/content/controllers/SettingController.ts | 41 ++++++++ src/content/index.ts | 116 +--------------------- src/content/presenters/ConsoleFramePresenter.ts | 25 +++++ src/content/routes.ts | 97 ++++++++++++++++++ src/content/usecases/ConsoleFrameUseCase.ts | 17 ++++ 7 files changed, 201 insertions(+), 127 deletions(-) delete mode 100644 src/content/console-frames.ts create mode 100644 src/content/controllers/ConsoleFrameController.ts create mode 100644 src/content/controllers/SettingController.ts create mode 100644 src/content/presenters/ConsoleFramePresenter.ts create mode 100644 src/content/routes.ts create mode 100644 src/content/usecases/ConsoleFrameUseCase.ts (limited to 'src/content/presenters') diff --git a/src/content/console-frames.ts b/src/content/console-frames.ts deleted file mode 100644 index b1b9bf6..0000000 --- a/src/content/console-frames.ts +++ /dev/null @@ -1,16 +0,0 @@ -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(); -}; - -export { initialize, blur }; 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/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 { + 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 { + await this.settingUseCase.reload(); + } +} diff --git a/src/content/index.ts b/src/content/index.ts index 06bb34f..660ebf5 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,122 +1,16 @@ -import * as consoleFrames from './console-frames'; +import { ConsoleFramePresenterImpl } from './presenters/ConsoleFramePresenter'; import consoleFrameStyle from './site-style'; -import MessageListener from './MessageListener'; -import FindController from './controllers/FindController'; -import MarkController from './controllers/MarkController'; -import FollowMasterController from './controllers/FollowMasterController'; -import FollowSlaveController from './controllers/FollowSlaveController'; -import FollowKeyController from './controllers/FollowKeyController'; -import * as messages from '../shared/messages'; -import InputDriver from './InputDriver'; -import KeymapController from './controllers/KeymapController'; -import AddonEnabledUseCase from './usecases/AddonEnabledUseCase'; -import SettingUseCase from './usecases/SettingUseCase'; -import * as blacklists from '../shared/blacklists'; -import MarkKeyController from './controllers/MarkKeyController'; -import AddonEnabledController from './controllers/AddonEnabledController'; +import * as routes from './routes'; -let listener = new MessageListener(); if (window.self === window.top) { - let findController = new FindController(); + routes.routeMasterComponents(); - let followMasterController = new FollowMasterController(); - 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: - window.focus(); - consoleFrames.blur(window.document); - break; - case messages.FOLLOW_START: - return followMasterController.followStart(message); - case messages.FOLLOW_RESPONSE_COUNT_TARGETS: - return followMasterController.responseCountTargets(message, sender); - case messages.FOLLOW_KEY_PRESS: - return followMasterController.keyPress(message); - } - return undefined; - }); - - let markController = new MarkController(); - let addonEnabledController = new AddonEnabledController(); - - new MessageListener().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; - }); - - consoleFrames.initialize(window.document); + new ConsoleFramePresenterImpl().initialize(); } -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; -}); +routes.routeComponents(); -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 style = window.document.createElement('style'); style.textContent = consoleFrameStyle; window.document.head.appendChild(style); - -// TODO move the following to a class -const reloadSettings = async() => { - let addonEnabledUseCase = new AddonEnabledUseCase(); - let settingUseCase = new SettingUseCase(); - - try { - let current = await settingUseCase.reload(); - let disabled = blacklists.includes( - current.blacklist, window.location.href, - ); - if (disabled) { - addonEnabledUseCase.disable(); - } else { - addonEnabledUseCase.enable(); - } - } catch (e) { - // Sometime sendMessage fails when background script is not ready. - console.warn(e); - setTimeout(() => reloadSettings(), 500); - } -}; -reloadSettings(); - -new MessageListener().onBackgroundMessage((message: messages.Message): any => { - let addonEnabledUseCase = new AddonEnabledUseCase(); - - switch (message.type) { - case messages.SETTINGS_CHANGED: - return reloadSettings(); - case messages.ADDON_TOGGLE_ENABLED: - return addonEnabledUseCase.toggle(); - } -}); 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/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/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(); + } +} -- cgit v1.2.3 From 6d9aaef18c9f48684c8bb99e53c586e9781a69f0 Mon Sep 17 00:00:00 2001 From: Shin'ya Ueoka Date: Sun, 19 May 2019 15:36:14 +0900 Subject: Add NavigationPresenter --- src/content/navigates.ts | 83 ------------ src/content/presenters/NavigationPresenter.ts | 98 ++++++++++++++ src/content/usecases/FocusUseCase.ts | 1 + src/content/usecases/NavigateUseCase.ts | 25 ++-- test/content/navigates.test.ts | 137 -------------------- .../content/presenters/NavigationPresenter.test.ts | 144 +++++++++++++++++++++ .../repositories/FollowKeyRepository.test.ts | 31 +++++ .../repositories/FollowMasterRepository.test.ts | 49 +++++++ .../repositories/FollowSlaveRepository.test.ts | 24 ++++ test/content/repositories/KeymapRepository.test.ts | 37 ++++++ .../content/repositories/MarkKeyRepository.test.ts | 36 ++++++ 11 files changed, 437 insertions(+), 228 deletions(-) delete mode 100644 src/content/navigates.ts create mode 100644 src/content/presenters/NavigationPresenter.ts delete mode 100644 test/content/navigates.test.ts create mode 100644 test/content/presenters/NavigationPresenter.test.ts create mode 100644 test/content/repositories/FollowKeyRepository.test.ts create mode 100644 test/content/repositories/FollowMasterRepository.test.ts create mode 100644 test/content/repositories/FollowSlaveRepository.test.ts create mode 100644 test/content/repositories/KeymapRepository.test.ts create mode 100644 test/content/repositories/MarkKeyRepository.test.ts (limited to 'src/content/presenters') 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, -}; - -// 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( - win: Window, - selector: string, - filter?: (e: E) => boolean, -): E | null { - let nodes = Array.from( - win.document.querySelectorAll(selector) as NodeListOf - ); - - 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(win, `link[rel~=${rel}][href]`); - if (link) { - win.location.href = link.href; - return; - } - - const pattern = REL_PATTERN[rel]; - - let a = selectLast(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/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, +}; + +// 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( + selector: string, + filter?: (e: E) => boolean, +): E | null { + let nodes = Array.from( + window.document.querySelectorAll(selector) as NodeListOf + ); + + 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(`link[rel~=${rel}][href]`); + if (link) { + window.location.href = link.href; + return; + } + + const pattern = REL_PATTERN[rel]; + + let a = selectLast(`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/usecases/FocusUseCase.ts b/src/content/usecases/FocusUseCase.ts index 615442d..0ad4021 100644 --- a/src/content/usecases/FocusUseCase.ts +++ b/src/content/usecases/FocusUseCase.ts @@ -1,5 +1,6 @@ import FocusPresenter, { FocusPresenterImpl } from '../presenters/FocusPresenter'; + export default class FocusUseCases { private presenter: FocusPresenter; diff --git a/src/content/usecases/NavigateUseCase.ts b/src/content/usecases/NavigateUseCase.ts index f790212..6f82d3f 100644 --- a/src/content/usecases/NavigateUseCase.ts +++ b/src/content/usecases/NavigateUseCase.ts @@ -1,27 +1,36 @@ -import * as navigates from '../navigates'; +import NavigationPresenter, { NavigationPresenterImpl } + from '../presenters/NavigationPresenter'; + +export default class NavigateUseCase { + private navigationPresenter: NavigationPresenter; + + constructor({ + navigationPresenter = new NavigationPresenterImpl(), + } = {}) { + this.navigationPresenter = navigationPresenter; + } -export default class NavigateClass { openHistoryPrev(): void { - navigates.historyPrev(window); + this.navigationPresenter.openHistoryPrev(); } openHistoryNext(): void { - navigates.historyNext(window); + this.navigationPresenter.openHistoryNext(); } openLinkPrev(): void { - navigates.linkPrev(window); + this.navigationPresenter.openLinkPrev(); } openLinkNext(): void { - navigates.linkNext(window); + this.navigationPresenter.openLinkNext(); } openParent(): void { - navigates.parent(window); + this.navigationPresenter.openParent(); } openRoot(): void { - navigates.root(window); + this.navigationPresenter.openRoot(); } } diff --git a/test/content/navigates.test.ts b/test/content/navigates.test.ts deleted file mode 100644 index 1d73344..0000000 --- a/test/content/navigates.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -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', () => { - describe('#linkPrev', () => { - it('navigates to elements whose rel attribute is "prev"', testPrev( - '' - )); - - it('navigates to elements whose rel attribute starts with "prev"', testPrev( - '' - )); - - it('navigates to elements whose rel attribute ends with "prev"', testPrev( - '' - )); - - it('navigates to elements whose rel attribute contains "prev"', testPrev( - '' - )); - - it('navigates to elements whose rel attribute is "prev"', testPrev( - '' - )); - - it('navigates to elements whose rel attribute starts with "prev"', testPrev( - 'click me' - )); - - it('navigates to elements whose rel attribute ends with "prev"', testPrev( - 'click me' - )); - - it('navigates to elements whose rel attribute contains "prev"', testPrev( - 'click me' - )); - - it('navigates to elements whose text matches "prev"', testPrev( - 'previewgo to prev' - )); - - it('navigates to elements whose text matches "previous"', testPrev( - 'previouslyprevious page' - )); - - it('navigates to elements whose decoded text matches "<<"', testPrev( - 'click me<<' - )); - - it('navigates to matching elements by clicking', testPrev( - `` - )); - - it('prefers link[rel~=prev] to a[rel~=prev]', testPrev( - '' - )); - - it('prefers a[rel~=prev] to a::text(pattern)', testPrev( - 'go to prev' - )); - }); - - describe('#linkNext', () => { - it('navigates to elements whose rel attribute is "next"', testNext( - '' - )); - - it('navigates to elements whose rel attribute starts with "next"', testNext( - '' - )); - - it('navigates to elements whose rel attribute ends with "next"', testNext( - '' - )); - - it('navigates to elements whose rel attribute contains "next"', testNext( - '' - )); - - it('navigates to elements whose rel attribute is "next"', testNext( - '' - )); - - it('navigates to elements whose rel attribute starts with "next"', testNext( - 'click me' - )); - - it('navigates to elements whose rel attribute ends with "next"', testNext( - 'click me' - )); - - it('navigates to elements whose rel attribute contains "next"', testNext( - 'click me' - )); - - it('navigates to elements whose text matches "next"', testNext( - 'inextricablego to next' - )); - - it('navigates to elements whose decoded text matches ">>"', testNext( - 'click me>>' - )); - - it('navigates to matching elements by clicking', testNext( - `` - )); - - it('prefers link[rel~=next] to a[rel~=next]', testNext( - '' - )); - - it('prefers a[rel~=next] to a::text(pattern)', testNext( - 'next page' - )); - }); - - describe('#parent', () => { - // NOTE: not able to test location - it('removes hash', () => { - window.location.hash = '#section-1'; - navigates.parent(window); - expect(document.location.hash).to.be.empty; - }); - }); -}); diff --git a/test/content/presenters/NavigationPresenter.test.ts b/test/content/presenters/NavigationPresenter.test.ts new file mode 100644 index 0000000..c1aca9a --- /dev/null +++ b/test/content/presenters/NavigationPresenter.test.ts @@ -0,0 +1,144 @@ +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 elements whose rel attribute is "prev"', testPrev( + '' + )); + + it('navigates to elements whose rel attribute starts with "prev"', testPrev( + '' + )); + + it('navigates to elements whose rel attribute ends with "prev"', testPrev( + '' + )); + + it('navigates to elements whose rel attribute contains "prev"', testPrev( + '' + )); + + it('navigates to elements whose rel attribute is "prev"', testPrev( + '' + )); + + it('navigates to elements whose rel attribute starts with "prev"', testPrev( + 'click me' + )); + + it('navigates to elements whose rel attribute ends with "prev"', testPrev( + 'click me' + )); + + it('navigates to elements whose rel attribute contains "prev"', testPrev( + 'click me' + )); + + it('navigates to elements whose text matches "prev"', testPrev( + 'previewgo to prev' + )); + + it('navigates to elements whose text matches "previous"', testPrev( + 'previouslyprevious page' + )); + + it('navigates to elements whose decoded text matches "<<"', testPrev( + 'click me<<' + )); + + it('navigates to matching elements by clicking', testPrev( + `` + )); + + it('prefers link[rel~=prev] to a[rel~=prev]', testPrev( + '' + )); + + it('prefers a[rel~=prev] to a::text(pattern)', testPrev( + 'go to prev' + )); + }); + + describe('#linkNext', () => { + it('navigates to elements whose rel attribute is "next"', testNext( + '' + )); + + it('navigates to elements whose rel attribute starts with "next"', testNext( + '' + )); + + it('navigates to elements whose rel attribute ends with "next"', testNext( + '' + )); + + it('navigates to elements whose rel attribute contains "next"', testNext( + '' + )); + + it('navigates to elements whose rel attribute is "next"', testNext( + '' + )); + + it('navigates to elements whose rel attribute starts with "next"', testNext( + 'click me' + )); + + it('navigates to elements whose rel attribute ends with "next"', testNext( + 'click me' + )); + + it('navigates to elements whose rel attribute contains "next"', testNext( + 'click me' + )); + + it('navigates to elements whose text matches "next"', testNext( + 'inextricablego to next' + )); + + it('navigates to elements whose decoded text matches ">>"', testNext( + 'click me>>' + )); + + it('navigates to matching elements by clicking', testNext( + `` + )); + + it('prefers link[rel~=next] to a[rel~=next]', testNext( + '' + )); + + it('prefers a[rel~=next] to a::text(pattern)', testNext( + 'next page' + )); + }); + + describe('#parent', () => { + // NOTE: not able to test location + it('removes hash', () => { + window.location.hash = '#section-1'; + sut.openParent(); + expect(document.location.hash).to.be.empty; + }); + }); +}); 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; + }); + }); +}); + -- cgit v1.2.3