Merge pull request #587 from ueokande/refactor-content

Refactor content scripts
jh-changes
Shin'ya Ueoka 5 years ago committed by GitHub
commit 3f4bc62ed5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .eslintrc
  2. 6
      src/background/infrastructures/ContentMessageClient.ts
  3. 2
      src/console/actions/console.ts
  4. 3
      src/console/components/Console.tsx
  5. 20
      src/content/InputDriver.ts
  6. 8
      src/content/MessageListener.ts
  7. 19
      src/content/actions/addon.ts
  8. 100
      src/content/actions/find.ts
  9. 32
      src/content/actions/follow-controller.ts
  10. 122
      src/content/actions/index.ts
  11. 17
      src/content/actions/input.ts
  12. 46
      src/content/actions/mark.ts
  13. 107
      src/content/actions/operation.ts
  14. 28
      src/content/actions/setting.ts
  15. 16
      src/content/client/AddonIndicatorClient.ts
  16. 11
      src/content/client/BackgroundClient.ts
  17. 30
      src/content/client/ConsoleClient.ts
  18. 25
      src/content/client/FindClient.ts
  19. 23
      src/content/client/FindMasterClient.ts
  20. 47
      src/content/client/FollowMasterClient.ts
  21. 76
      src/content/client/FollowSlaveClient.ts
  22. 28
      src/content/client/MarkClient.ts
  23. 17
      src/content/client/SettingClient.ts
  24. 22
      src/content/client/TabsClient.ts
  25. 231
      src/content/components/common/follow.ts
  26. 62
      src/content/components/common/hint.ts
  27. 61
      src/content/components/common/index.ts
  28. 68
      src/content/components/common/keymapper.ts
  29. 79
      src/content/components/common/mark.ts
  30. 3
      src/content/components/frame-content.ts
  31. 46
      src/content/components/top-content/find.ts
  32. 166
      src/content/components/top-content/follow-controller.ts
  33. 51
      src/content/components/top-content/index.ts
  34. 38
      src/content/console-frames.ts
  35. 19
      src/content/controllers/AddonEnabledController.ts
  36. 16
      src/content/controllers/ConsoleFrameController.ts
  37. 24
      src/content/controllers/FindController.ts
  38. 21
      src/content/controllers/FollowKeyController.ts
  39. 31
      src/content/controllers/FollowMasterController.ts
  40. 32
      src/content/controllers/FollowSlaveController.ts
  41. 148
      src/content/controllers/KeymapController.ts
  42. 16
      src/content/controllers/MarkController.ts
  43. 31
      src/content/controllers/MarkKeyController.ts
  44. 41
      src/content/controllers/SettingController.ts
  45. 47
      src/content/domains/Key.ts
  46. 64
      src/content/domains/KeySequence.ts
  47. 0
      src/content/domains/Mark.ts
  48. 15
      src/content/focuses.ts
  49. 16
      src/content/index.ts
  50. 83
      src/content/navigates.ts
  51. 25
      src/content/presenters/ConsoleFramePresenter.ts
  52. 52
      src/content/presenters/FindPresenter.ts
  53. 25
      src/content/presenters/FocusPresenter.ts
  54. 134
      src/content/presenters/FollowPresenter.ts
  55. 127
      src/content/presenters/Hint.ts
  56. 98
      src/content/presenters/NavigationPresenter.ts
  57. 133
      src/content/presenters/ScrollPresenter.ts
  58. 22
      src/content/reducers/addon.ts
  59. 25
      src/content/reducers/find.ts
  60. 40
      src/content/reducers/follow-controller.ts
  61. 21
      src/content/reducers/index.ts
  62. 26
      src/content/reducers/input.ts
  63. 35
      src/content/reducers/mark.ts
  64. 40
      src/content/reducers/setting.ts
  65. 19
      src/content/repositories/AddonEnabledRepository.ts
  66. 46
      src/content/repositories/ClipboardRepository.ts
  67. 19
      src/content/repositories/FindRepository.ts
  68. 35
      src/content/repositories/FollowKeyRepository.ts
  69. 59
      src/content/repositories/FollowMasterRepository.ts
  70. 31
      src/content/repositories/FollowSlaveRepository.ts
  71. 24
      src/content/repositories/KeymapRepository.ts
  72. 52
      src/content/repositories/MarkKeyRepository.ts
  73. 25
      src/content/repositories/MarkRepository.ts
  74. 21
      src/content/repositories/SettingRepository.ts
  75. 97
      src/content/routes.ts
  76. 8
      src/content/store/index.ts
  77. 41
      src/content/urls.ts
  78. 40
      src/content/usecases/AddonEnabledUseCase.ts
  79. 44
      src/content/usecases/ClipboardUseCase.ts
  80. 17
      src/content/usecases/ConsoleFrameUseCase.ts
  81. 20
      src/content/usecases/FindSlaveUseCase.ts
  82. 81
      src/content/usecases/FindUseCase.ts
  83. 16
      src/content/usecases/FocusUseCase.ts
  84. 150
      src/content/usecases/FollowMasterUseCase.ts
  85. 91
      src/content/usecases/FollowSlaveUseCase.ts
  86. 38
      src/content/usecases/HintKeyProducer.ts
  87. 87
      src/content/usecases/KeymapUseCase.ts
  88. 36
      src/content/usecases/MarkKeyUseCase.ts
  89. 66
      src/content/usecases/MarkUseCase.ts
  90. 36
      src/content/usecases/NavigateUseCase.ts
  91. 58
      src/content/usecases/ScrollUseCase.ts
  92. 24
      src/content/usecases/SettingUseCase.ts
  93. 78
      src/shared/messages.ts
  94. 10
      src/shared/urls.ts
  95. 129
      test/content/InputDriver.test.ts
  96. 34
      test/content/actions/follow-controller.test.ts
  97. 19
      test/content/actions/input.test.ts
  98. 35
      test/content/actions/mark.test.ts
  99. 43
      test/content/actions/setting.test.ts
  100. 17
      test/content/components/common/follow.html
  101. Some files were not shown because too many files have changed in this diff Show More

@ -79,6 +79,6 @@
"react/jsx-indent": ["error", 2],
"react/prop-types": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/no-unused-vars": "error"
"@typescript-eslint/no-unused-vars": ["error", { args: "none" }],
}
}

@ -14,10 +14,10 @@ export default class ContentMessageClient {
}
async getAddonEnabled(tabId: number): Promise<boolean> {
let { enabled } = await browser.tabs.sendMessage(tabId, {
let enabled = await browser.tabs.sendMessage(tabId, {
type: messages.ADDON_ENABLED_QUERY,
}) as { enabled: boolean };
return enabled;
});
return enabled as any as boolean;
}
toggleAddonEnabled(tabId: number): Promise<void> {

@ -53,7 +53,7 @@ const enterCommand = async(
return hideCommand();
};
const enterFind = (text: string): actions.ConsoleAction => {
const enterFind = (text?: string): actions.ConsoleAction => {
window.top.postMessage(JSON.stringify({
type: messages.CONSOLE_ENTER_FIND,
text,

@ -38,7 +38,8 @@ class Console extends React.Component<Props> {
if (this.props.mode === 'command') {
return this.props.dispatch(consoleActions.enterCommand(value));
} else if (this.props.mode === 'find') {
return this.props.dispatch(consoleActions.enterFind(value));
return this.props.dispatch(consoleActions.enterFind(
value === '' ? undefined : value));
}
}

@ -1,14 +1,14 @@
import * as dom from '../../../shared/utils/dom';
import * as keys from '../../../shared/utils/keys';
import * as dom from '../shared/utils/dom';
import Key, * as keys from './domains/Key';
const cancelKey = (e: KeyboardEvent): boolean => {
return e.key === 'Escape' || e.key === '[' && e.ctrlKey;
};
export default class InputComponent {
export default class InputDriver {
private pressed: {[key: string]: string} = {};
private onKeyListeners: ((key: keys.Key) => boolean)[] = [];
private onKeyListeners: ((key: Key) => boolean)[] = [];
constructor(target: HTMLElement) {
this.pressed = {};
@ -19,11 +19,11 @@ export default class InputComponent {
target.addEventListener('keyup', this.onKeyUp.bind(this));
}
onKey(cb: (key: keys.Key) => boolean) {
onKey(cb: (key: Key) => boolean) {
this.onKeyListeners.push(cb);
}
onKeyPress(e: KeyboardEvent) {
private onKeyPress(e: KeyboardEvent) {
if (this.pressed[e.key] && this.pressed[e.key] !== 'keypress') {
return;
}
@ -31,7 +31,7 @@ export default class InputComponent {
this.capture(e);
}
onKeyDown(e: KeyboardEvent) {
private onKeyDown(e: KeyboardEvent) {
if (this.pressed[e.key] && this.pressed[e.key] !== 'keydown') {
return;
}
@ -39,12 +39,12 @@ export default class InputComponent {
this.capture(e);
}
onKeyUp(e: KeyboardEvent) {
private onKeyUp(e: KeyboardEvent) {
delete this.pressed[e.key];
}
// eslint-disable-next-line max-statements
capture(e: KeyboardEvent) {
private capture(e: KeyboardEvent) {
let target = e.target;
if (!(target instanceof HTMLElement)) {
return;
@ -71,7 +71,7 @@ export default class InputComponent {
}
}
fromInput(e: Element) {
private fromInput(e: Element) {
return e instanceof HTMLInputElement ||
e instanceof HTMLTextAreaElement ||
e instanceof HTMLSelectElement ||

@ -1,14 +1,16 @@
import { Message, valueOf } from '../shared/messages';
export type WebMessageSender = Window | MessagePort | ServiceWorker | null;
export type WebExtMessageSender = browser.runtime.MessageSender;
export default class MessageListener {
onWebMessage(
listener: (msg: Message, sender: WebMessageSender) => void,
listener: (msg: Message, sender: Window) => void,
) {
window.addEventListener('message', (event: MessageEvent) => {
let sender = event.source;
if (!(sender instanceof Window)) {
return;
}
let message = null;
try {
message = JSON.parse(event.data);
@ -25,7 +27,7 @@ export default class MessageListener {
) {
browser.runtime.onMessage.addListener(
(msg: any, sender: WebExtMessageSender) => {
listener(valueOf(msg), sender);
return listener(valueOf(msg), sender);
},
);
}

@ -1,19 +0,0 @@
import * as messages from '../../shared/messages';
import * as actions from './index';
const enable = (): Promise<actions.AddonAction> => setEnabled(true);
const disable = (): Promise<actions.AddonAction> => setEnabled(false);
const setEnabled = async(enabled: boolean): Promise<actions.AddonAction> => {
await browser.runtime.sendMessage({
type: messages.ADDON_ENABLED_RESPONSE,
enabled,
});
return {
type: actions.ADDON_SET_ENABLED,
enabled,
};
};
export { enable, disable, setEnabled };

@ -1,100 +0,0 @@
//
// window.find(aString, aCaseSensitive, aBackwards, aWrapAround,
// aWholeWord, aSearchInFrames);
//
// NOTE: window.find is not standard API
// https://developer.mozilla.org/en-US/docs/Web/API/Window/find
import * as messages from '../../shared/messages';
import * as actions from './index';
import * as consoleFrames from '../console-frames';
interface MyWindow extends Window {
find(
aString: string,
aCaseSensitive?: boolean,
aBackwards?: boolean,
aWrapAround?: boolean,
aWholeWord?: boolean,
aSearchInFrames?: boolean,
aShowDialog?: boolean): boolean;
}
// eslint-disable-next-line no-var, vars-on-top, init-declarations
declare var window: MyWindow;
const find = (str: string, backwards: boolean): boolean => {
let caseSensitive = false;
let wrapScan = true;
// NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work
// because of same origin policy
// eslint-disable-next-line no-extra-parens
let found = window.find(str, caseSensitive, backwards, wrapScan);
if (found) {
return found;
}
let sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
}
// eslint-disable-next-line no-extra-parens
return window.find(str, caseSensitive, backwards, wrapScan);
};
// eslint-disable-next-line max-statements
const findNext = async(
currentKeyword: string, reset: boolean, backwards: boolean,
): Promise<actions.FindAction> => {
if (reset) {
let sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
}
}
let keyword = currentKeyword;
if (currentKeyword) {
browser.runtime.sendMessage({
type: messages.FIND_SET_KEYWORD,
keyword: currentKeyword,
});
} else {
keyword = await browser.runtime.sendMessage({
type: messages.FIND_GET_KEYWORD,
});
}
if (!keyword) {
await consoleFrames.postError('No previous search keywords');
return { type: actions.NOOP };
}
let found = find(keyword, backwards);
if (found) {
consoleFrames.postInfo('Pattern found: ' + keyword);
} else {
consoleFrames.postError('Pattern not found: ' + keyword);
}
return {
type: actions.FIND_SET_KEYWORD,
keyword,
found,
};
};
const next = (
currentKeyword: string, reset: boolean,
): Promise<actions.FindAction> => {
return findNext(currentKeyword, reset, false);
};
const prev = (
currentKeyword: string, reset: boolean,
): Promise<actions.FindAction> => {
return findNext(currentKeyword, reset, true);
};
export { next, prev };

@ -1,32 +0,0 @@
import * as actions from './index';
const enable = (
newTab: boolean, background: boolean,
): actions.FollowAction => {
return {
type: actions.FOLLOW_CONTROLLER_ENABLE,
newTab,
background,
};
};
const disable = (): actions.FollowAction => {
return {
type: actions.FOLLOW_CONTROLLER_DISABLE,
};
};
const keyPress = (key: string): actions.FollowAction => {
return {
type: actions.FOLLOW_CONTROLLER_KEY_PRESS,
key: key
};
};
const backspace = (): actions.FollowAction => {
return {
type: actions.FOLLOW_CONTROLLER_BACKSPACE,
};
};
export { enable, disable, keyPress, backspace };

@ -1,122 +0,0 @@
import Redux from 'redux';
import Settings from '../../shared/Settings';
import * as keyUtils from '../../shared/utils/keys';
// Enable/disable
export const ADDON_SET_ENABLED = 'addon.set.enabled';
// Find
export const FIND_SET_KEYWORD = 'find.set.keyword';
// Settings
export const SETTING_SET = 'setting.set';
// User input
export const INPUT_KEY_PRESS = 'input.key.press';
export const INPUT_CLEAR_KEYS = 'input.clear.keys';
// Completion
export const COMPLETION_SET_ITEMS = 'completion.set.items';
export const COMPLETION_SELECT_NEXT = 'completions.select.next';
export const COMPLETION_SELECT_PREV = 'completions.select.prev';
// Follow
export const FOLLOW_CONTROLLER_ENABLE = 'follow.controller.enable';
export const FOLLOW_CONTROLLER_DISABLE = 'follow.controller.disable';
export const FOLLOW_CONTROLLER_KEY_PRESS = 'follow.controller.key.press';
export const FOLLOW_CONTROLLER_BACKSPACE = 'follow.controller.backspace';
// Mark
export const MARK_START_SET = 'mark.start.set';
export const MARK_START_JUMP = 'mark.start.jump';
export const MARK_CANCEL = 'mark.cancel';
export const MARK_SET_LOCAL = 'mark.set.local';
export const NOOP = 'noop';
export interface AddonSetEnabledAction extends Redux.Action {
type: typeof ADDON_SET_ENABLED;
enabled: boolean;
}
export interface FindSetKeywordAction extends Redux.Action {
type: typeof FIND_SET_KEYWORD;
keyword: string;
found: boolean;
}
export interface SettingSetAction extends Redux.Action {
type: typeof SETTING_SET;
settings: Settings,
}
export interface InputKeyPressAction extends Redux.Action {
type: typeof INPUT_KEY_PRESS;
key: keyUtils.Key;
}
export interface InputClearKeysAction extends Redux.Action {
type: typeof INPUT_CLEAR_KEYS;
}
export interface FollowControllerEnableAction extends Redux.Action {
type: typeof FOLLOW_CONTROLLER_ENABLE;
newTab: boolean;
background: boolean;
}
export interface FollowControllerDisableAction extends Redux.Action {
type: typeof FOLLOW_CONTROLLER_DISABLE;
}
export interface FollowControllerKeyPressAction extends Redux.Action {
type: typeof FOLLOW_CONTROLLER_KEY_PRESS;
key: string;
}
export interface FollowControllerBackspaceAction extends Redux.Action {
type: typeof FOLLOW_CONTROLLER_BACKSPACE;
}
export interface MarkStartSetAction extends Redux.Action {
type: typeof MARK_START_SET;
}
export interface MarkStartJumpAction extends Redux.Action {
type: typeof MARK_START_JUMP;
}
export interface MarkCancelAction extends Redux.Action {
type: typeof MARK_CANCEL;
}
export interface MarkSetLocalAction extends Redux.Action {
type: typeof MARK_SET_LOCAL;
key: string;
x: number;
y: number;
}
export interface NoopAction extends Redux.Action {
type: typeof NOOP;
}
export type AddonAction = AddonSetEnabledAction;
export type FindAction = FindSetKeywordAction | NoopAction;
export type SettingAction = SettingSetAction;
export type InputAction = InputKeyPressAction | InputClearKeysAction;
export type FollowAction =
FollowControllerEnableAction | FollowControllerDisableAction |
FollowControllerKeyPressAction | FollowControllerBackspaceAction;
export type MarkAction =
MarkStartSetAction | MarkStartJumpAction |
MarkCancelAction | MarkSetLocalAction | NoopAction;
export type Action =
AddonAction |
FindAction |
SettingAction |
InputAction |
FollowAction |
MarkAction |
NoopAction;

@ -1,17 +0,0 @@
import * as actions from './index';
import * as keyUtils from '../../shared/utils/keys';
const keyPress = (key: keyUtils.Key): actions.InputAction => {
return {
type: actions.INPUT_KEY_PRESS,
key,
};
};
const clearKeys = (): actions.InputAction => {
return {
type: actions.INPUT_CLEAR_KEYS
};
};
export { keyPress, clearKeys };

@ -1,46 +0,0 @@
import * as actions from './index';
import * as messages from '../../shared/messages';
const startSet = (): actions.MarkAction => {
return { type: actions.MARK_START_SET };
};
const startJump = (): actions.MarkAction => {
return { type: actions.MARK_START_JUMP };
};
const cancel = (): actions.MarkAction => {
return { type: actions.MARK_CANCEL };
};
const setLocal = (key: string, x: number, y: number): actions.MarkAction => {
return {
type: actions.MARK_SET_LOCAL,
key,
x,
y,
};
};
const setGlobal = (key: string, x: number, y: number): actions.MarkAction => {
browser.runtime.sendMessage({
type: messages.MARK_SET_GLOBAL,
key,
x,
y,
});
return { type: actions.NOOP };
};
const jumpGlobal = (key: string): actions.MarkAction => {
browser.runtime.sendMessage({
type: messages.MARK_JUMP_GLOBAL,
key,
});
return { type: actions.NOOP };
};
export {
startSet, startJump, cancel, setLocal,
setGlobal, jumpGlobal,
};

@ -1,107 +0,0 @@
import * as operations from '../../shared/operations';
import * as actions from './index';
import * as messages from '../../shared/messages';
import * as scrolls from '../scrolls';
import * as navigates from '../navigates';
import * as focuses from '../focuses';
import * as urls from '../urls';
import * as consoleFrames from '../console-frames';
import * as addonActions from './addon';
import * as markActions from './mark';
// eslint-disable-next-line complexity, max-lines-per-function
const exec = (
operation: operations.Operation,
settings: any,
addonEnabled: boolean,
): Promise<actions.Action> | actions.Action => {
let smoothscroll = settings.properties.smoothscroll;
switch (operation.type) {
case operations.ADDON_ENABLE:
return addonActions.enable();
case operations.ADDON_DISABLE:
return addonActions.disable();
case operations.ADDON_TOGGLE_ENABLED:
return addonActions.setEnabled(!addonEnabled);
case operations.FIND_NEXT:
window.top.postMessage(JSON.stringify({
type: messages.FIND_NEXT,
}), '*');
break;
case operations.FIND_PREV:
window.top.postMessage(JSON.stringify({
type: messages.FIND_PREV,
}), '*');
break;
case operations.SCROLL_VERTICALLY:
scrolls.scrollVertically(operation.count, smoothscroll);
break;
case operations.SCROLL_HORIZONALLY:
scrolls.scrollHorizonally(operation.count, smoothscroll);
break;
case operations.SCROLL_PAGES:
scrolls.scrollPages(operation.count, smoothscroll);
break;
case operations.SCROLL_TOP:
scrolls.scrollToTop(smoothscroll);
break;
case operations.SCROLL_BOTTOM:
scrolls.scrollToBottom(smoothscroll);
break;
case operations.SCROLL_HOME:
scrolls.scrollToHome(smoothscroll);
break;
case operations.SCROLL_END:
scrolls.scrollToEnd(smoothscroll);
break;
case operations.FOLLOW_START:
window.top.postMessage(JSON.stringify({
type: messages.FOLLOW_START,
newTab: operation.newTab,
background: operation.background,
}), '*');
break;
case operations.MARK_SET_PREFIX:
return markActions.startSet();
case operations.MARK_JUMP_PREFIX:
return markActions.startJump();
case operations.NAVIGATE_HISTORY_PREV:
navigates.historyPrev(window);
break;
case operations.NAVIGATE_HISTORY_NEXT:
navigates.historyNext(window);
break;
case operations.NAVIGATE_LINK_PREV:
navigates.linkPrev(window);
break;
case operations.NAVIGATE_LINK_NEXT:
navigates.linkNext(window);
break;
case operations.NAVIGATE_PARENT:
navigates.parent(window);
break;
case operations.NAVIGATE_ROOT:
navigates.root(window);
break;
case operations.FOCUS_INPUT:
focuses.focusInput();
break;
case operations.URLS_YANK:
urls.yank(window);
consoleFrames.postInfo('Yanked ' + window.location.href);
break;
case operations.URLS_PASTE:
urls.paste(
window, operation.newTab ? operation.newTab : false, settings.search
);
break;
default:
browser.runtime.sendMessage({
type: messages.BACKGROUND_OPERATION,
operation,
});
}
return { type: actions.NOOP };
};
export { exec };

@ -1,28 +0,0 @@
import * as actions from './index';
import * as operations from '../../shared/operations';
import * as messages from '../../shared/messages';
import Settings, { Keymaps } from '../../shared/Settings';
const reservedKeymaps: Keymaps = {
'<Esc>': { type: operations.CANCEL },
'<C-[>': { type: operations.CANCEL },
};
const set = (settings: Settings): actions.SettingAction => {
return {
type: actions.SETTING_SET,
settings: {
...settings,
keymaps: { ...settings.keymaps, ...reservedKeymaps },
}
};
};
const load = async(): Promise<actions.SettingAction> => {
let settings = await browser.runtime.sendMessage({
type: messages.SETTINGS_QUERY,
});
return set(settings);
};
export { set, load };

@ -0,0 +1,16 @@
import * as messages from '../../shared/messages';
export default interface AddonIndicatorClient {
setEnabled(enabled: boolean): Promise<void>;
// eslint-disable-next-line semi
}
export class AddonIndicatorClientImpl implements AddonIndicatorClient {
setEnabled(enabled: boolean): Promise<void> {
return browser.runtime.sendMessage({
type: messages.ADDON_ENABLED_RESPONSE,
enabled,
});
}
}

@ -0,0 +1,11 @@
import * as operations from '../../shared/operations';
import * as messages from '../../shared/messages';
export default class BackgroundClient {
execBackgroundOp(op: operations.Operation): Promise<void> {
return browser.runtime.sendMessage({
type: messages.BACKGROUND_OPERATION,
operation: op,
});
}
}

@ -0,0 +1,30 @@
import * as messages from '../../shared/messages';
export default interface ConsoleClient {
info(text: string): Promise<void>;
error(text: string): Promise<void>;
// eslint-disable-next-line semi
}
export class ConsoleClientImpl implements ConsoleClient {
async info(text: string): Promise<void> {
await browser.runtime.sendMessage({
type: messages.CONSOLE_FRAME_MESSAGE,
message: {
type: messages.CONSOLE_SHOW_INFO,
text,
},
});
}
async error(text: string): Promise<void> {
await browser.runtime.sendMessage({
type: messages.CONSOLE_FRAME_MESSAGE,
message: {
type: messages.CONSOLE_SHOW_ERROR,
text,
},
});
}
}

@ -0,0 +1,25 @@
import * as messages from '../../shared/messages';
export default interface FindClient {
getGlobalLastKeyword(): Promise<string | null>;
setGlobalLastKeyword(keyword: string): Promise<void>;
// eslint-disable-next-line semi
}
export class FindClientImpl implements FindClient {
async getGlobalLastKeyword(): Promise<string | null> {
let keyword = await browser.runtime.sendMessage({
type: messages.FIND_GET_KEYWORD,
});
return keyword as string;
}
async setGlobalLastKeyword(keyword: string): Promise<void> {
await browser.runtime.sendMessage({
type: messages.FIND_SET_KEYWORD,
keyword: keyword,
});
}
}

@ -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,
}), '*');
}
}

@ -0,0 +1,47 @@
import * as messages from '../../shared/messages';
import Key from '../domains/Key';
export default interface FollowMasterClient {
startFollow(newTab: boolean, background: boolean): void;
responseHintCount(count: number): void;
sendKey(key: Key): void;
// eslint-disable-next-line semi
}
export class FollowMasterClientImpl implements FollowMasterClient {
private window: Window;
constructor(window: Window) {
this.window = window;
}
startFollow(newTab: boolean, background: boolean): void {
this.postMessage({
type: messages.FOLLOW_START,
newTab,
background,
});
}
responseHintCount(count: number): void {
this.postMessage({
type: messages.FOLLOW_RESPONSE_COUNT_TARGETS,
count,
});
}
sendKey(key: Key): void {
this.postMessage({
type: messages.FOLLOW_KEY_PRESS,
key: key.key,
ctrlKey: key.ctrlKey || false,
});
}
private postMessage(msg: messages.Message): void {
this.window.postMessage(JSON.stringify(msg), '*');
}
}

@ -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), '*');
}
}

@ -0,0 +1,28 @@
import Mark from '../domains/Mark';
import * as messages from '../../shared/messages';
export default interface MarkClient {
setGloablMark(key: string, mark: Mark): Promise<void>;
jumpGlobalMark(key: string): Promise<void>;
// eslint-disable-next-line semi
}
export class MarkClientImpl implements MarkClient {
async setGloablMark(key: string, mark: Mark): Promise<void> {
await browser.runtime.sendMessage({
type: messages.MARK_SET_GLOBAL,
key,
x: mark.x,
y: mark.y,
});
}
async jumpGlobalMark(key: string): Promise<void> {
await browser.runtime.sendMessage({
type: messages.MARK_JUMP_GLOBAL,
key,
});
}
}

@ -0,0 +1,17 @@
import Settings from '../../shared/Settings';
import * as messages from '../../shared/messages';
export default interface SettingClient {
load(): Promise<Settings>;
// eslint-disable-next-line semi
}
export class SettingClientImpl {
async load(): Promise<Settings> {
let settings = await browser.runtime.sendMessage({
type: messages.SETTINGS_QUERY,
});
return settings as Settings;
}
}

@ -0,0 +1,22 @@
import * as messages from '../../shared/messages';
export default interface TabsClient {
openUrl(url: string, newTab: boolean, background?: boolean): Promise<void>;
// eslint-disable-next-line semi
}
export class TabsClientImpl implements TabsClient {
async openUrl(
url: string,
newTab: boolean,
background?: boolean,
): Promise<void> {
await browser.runtime.sendMessage({
type: messages.OPEN_URL,
url,
newTab,
background,
});
}
}

@ -1,231 +0,0 @@
import MessageListener from '../../MessageListener';
import Hint from './hint';
import * as dom from '../../../shared/utils/dom';
import * as messages from '../../../shared/messages';
import * as keyUtils from '../../../shared/utils/keys';
const TARGET_SELECTOR = [
'a', 'button', 'input', 'textarea', 'area',
'[contenteditable=true]', '[contenteditable=""]', '[tabindex]',
'[role="button"]', 'summary'
].join(',');
interface Size {
width: number;
height: number;
}
interface Point {
x: number;
y: number;
}
const inViewport = (
win: Window,
element: Element,
viewSize: Size,
framePosition: Point,
): boolean => {
let {
top, left, bottom, right
} = dom.viewportRect(element);
let doc = win.document;
let frameWidth = doc.documentElement.clientWidth;
let frameHeight = doc.documentElement.clientHeight;
if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) {
// out of frame
return false;
}
if (right + framePosition.x < 0 || bottom + framePosition.y < 0 ||
left + framePosition.x > viewSize.width ||
top + framePosition.y > viewSize.height) {
// out of viewport
return false;
}
return true;
};
const isAriaHiddenOrAriaDisabled = (win: Window, element: Element): boolean => {
if (!element || win.document.documentElement === element) {
return false;
}
for (let attr of ['aria-hidden', 'aria-disabled']) {
let value = element.getAttribute(attr);
if (value !== null) {
let hidden = value.toLowerCase();
if (hidden === '' || hidden === 'true') {
return true;
}
}
}
return isAriaHiddenOrAriaDisabled(win, element.parentElement as Element);
};
export default class Follow {
private win: Window;
private newTab: boolean;
private background: boolean;
private hints: {[key: string]: Hint };
private targets: HTMLElement[] = [];
constructor(win: Window) {
this.win = win;
this.newTab = false;
this.background = false;
this.hints = {};
this.targets = [];
new MessageListener().onWebMessage(this.onMessage.bind(this));
}
key(key: keyUtils.Key): boolean {
if (Object.keys(this.hints).length === 0) {
return false;
}
this.win.parent.postMessage(JSON.stringify({
type: messages.FOLLOW_KEY_PRESS,
key: key.key,
ctrlKey: key.ctrlKey,
}), '*');
return true;
}
openLink(element: HTMLAreaElement|HTMLAnchorElement) {
// Browser prevent new tab by link with target='_blank'
if (!this.newTab && element.getAttribute('target') !== '_blank') {
element.click();
return;
}
let href = element.getAttribute('href');
// eslint-disable-next-line no-script-url
if (!href || href === '#' || href.toLowerCase().startsWith('javascript:')) {
return;
}
return browser.runtime.sendMessage({
type: messages.OPEN_URL,
url: element.href,
newTab: true,
background: this.background,
});
}
countHints(sender: any, viewSize: Size, framePosition: Point) {
this.targets = Follow.getTargetElements(this.win, viewSize, framePosition);
sender.postMessage(JSON.stringify({
type: messages.FOLLOW_RESPONSE_COUNT_TARGETS,
count: this.targets.length,
}), '*');
}
createHints(keysArray: string[], newTab: boolean, background: boolean) {
if (keysArray.length !== this.targets.length) {
throw new Error('illegal hint count');
}
this.newTab = newTab;
this.background = background;
this.hints = {};
for (let i = 0; i < keysArray.length; ++i) {
let keys = keysArray[i];
let hint = new Hint(this.targets[i], keys);
this.hints[keys] = hint;
}
}
showHints(keys: string) {
Object.keys(this.hints).filter(key => key.startsWith(keys))
.forEach(key => this.hints[key].show());
Object.keys(this.hints).filter(key => !key.startsWith(keys))
.forEach(key => this.hints[key].hide());
}
removeHints() {
Object.keys(this.hints).forEach((key) => {
this.hints[key].remove();
});
this.hints = {};
this.targets = [];
}
activateHints(keys: string) {
let hint = this.hints[keys];
if (!hint) {
return;
}
let element = hint.getTarget();
switch (element.tagName.toLowerCase()) {
case 'a':
return this.openLink(element as HTMLAnchorElement);
case 'area':
return this.openLink(element as HTMLAreaElement);
case 'input':
switch ((element as HTMLInputElement).type) {
case 'file':
case 'checkbox':
case 'radio':
case 'submit':
case 'reset':
case 'button':
case 'image':
case 'color':
return element.click();
default:
return element.focus();
}
case 'textarea':
return element.focus();
case 'button':
case 'summary':
return element.click();
default:
if (dom.isContentEditable(element)) {
return element.focus();
} else if (element.hasAttribute('tabindex')) {
return element.click();
}
}
}
onMessage(message: messages.Message, sender: any) {
switch (message.type) {
case messages.FOLLOW_REQUEST_COUNT_TARGETS:
return this.countHints(sender, message.viewSize, message.framePosition);
case messages.FOLLOW_CREATE_HINTS:
return this.createHints(
message.keysArray, message.newTab, message.background);
case messages.FOLLOW_SHOW_HINTS:
return this.showHints(message.keys);
case messages.FOLLOW_ACTIVATE:
return this.activateHints(message.keys);
case messages.FOLLOW_REMOVE_HINTS:
return this.removeHints();
}
}
static getTargetElements(
win: Window,
viewSize:
Size, framePosition: Point,
): HTMLElement[] {
let all = win.document.querySelectorAll(TARGET_SELECTOR);
let filtered = Array.prototype.filter.call(all, (element: HTMLElement) => {
let style = win.getComputedStyle(element);
// AREA's 'display' in Browser style is 'none'
return (element.tagName === 'AREA' || style.display !== 'none') &&
style.visibility !== 'hidden' &&
(element as HTMLInputElement).type !== 'hidden' &&
element.offsetHeight > 0 &&
!isAriaHiddenOrAriaDisabled(win, element) &&
inViewport(win, element, viewSize, framePosition);
});
return filtered;
}
}

@ -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;
}
}

@ -1,61 +0,0 @@
import InputComponent from './input';
import FollowComponent from './follow';
import MarkComponent from './mark';
import KeymapperComponent from './keymapper';
import * as settingActions from '../../actions/setting';
import * as messages from '../../../shared/messages';
import MessageListener from '../../MessageListener';
import * as addonActions from '../../actions/addon';
import * as blacklists from '../../../shared/blacklists';
import * as keys from '../../../shared/utils/keys';
import * as actions from '../../actions';
export default class Common {
private win: Window;
private store: any;
constructor(win: Window, store: any) {
const input = new InputComponent(win.document.body);
const follow = new FollowComponent(win);
const mark = new MarkComponent(store);
const keymapper = new KeymapperComponent(store);
input.onKey((key: keys.Key) => follow.key(key));
input.onKey((key: keys.Key) => mark.key(key));
input.onKey((key: keys.Key) => keymapper.key(key));
this.win = win;
this.store = store;
this.reloadSettings();
new MessageListener().onBackgroundMessage(this.onMessage.bind(this));
}
onMessage(message: messages.Message) {
let { enabled } = this.store.getState().addon;
switch (message.type) {
case messages.SETTINGS_CHANGED:
return this.reloadSettings();
case messages.ADDON_TOGGLE_ENABLED:
this.store.dispatch(addonActions.setEnabled(!enabled));
}
}
reloadSettings() {
try {
this.store.dispatch(settingActions.load())
.then((action: actions.SettingAction) => {
let enabled = !blacklists.includes(
action.settings.blacklist, this.win.location.href
);
this.store.dispatch(addonActions.setEnabled(enabled));
});
} catch (e) {
// Sometime sendMessage fails when background script is not ready.
console.warn(e);
setTimeout(() => this.reloadSettings(), 500);
}
}
}

@ -1,68 +0,0 @@
import * as inputActions from '../../actions/input';
import * as operationActions from '../../actions/operation';
import * as operations from '../../../shared/operations';
import * as keyUtils from '../../../shared/utils/keys';
const mapStartsWith = (
mapping: keyUtils.Key[],
keys: keyUtils.Key[],
): boolean => {
if (mapping.length < keys.length) {
return false;
}
for (let i = 0; i < keys.length; ++i) {
if (!keyUtils.equals(mapping[i], keys[i])) {
return false;
}
}
return true;
};
export default class KeymapperComponent {
private store: any;
constructor(store: any) {
this.store = store;
}
// eslint-disable-next-line max-statements
key(key: keyUtils.Key): boolean {
this.store.dispatch(inputActions.keyPress(key));
let state = this.store.getState();
let input = state.input;
let keymaps = new Map<keyUtils.Key[], operations.Operation>(
state.setting.keymaps.map(
(e: {key: keyUtils.Key[], op: operations.Operation}) => [e.key, e.op],
)
);
let matched = Array.from(keymaps.keys()).filter(
(mapping: keyUtils.Key[]) => {
return mapStartsWith(mapping, input.keys);
});
if (!state.addon.enabled) {
// available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if
// the addon disabled
matched = matched.filter((keys) => {
let type = (keymaps.get(keys) as operations.Operation).type;
return type === operations.ADDON_ENABLE ||
type === operations.ADDON_TOGGLE_ENABLED;
});
}
if (matched.length === 0) {
this.store.dispatch(inputActions.clearKeys());
return false;
} else if (matched.length > 1 ||
matched.length === 1 && input.keys.length < matched[0].length) {
return true;
}
let operation = keymaps.get(matched[0]) as operations.Operation;
let act = operationActions.exec(
operation, state.setting, state.addon.enabled
);
this.store.dispatch(act);
this.store.dispatch(inputActions.clearKeys());
return true;
}
}

@ -1,79 +0,0 @@
import * as markActions from '../../actions/mark';
import * as scrolls from '../..//scrolls';
import * as consoleFrames from '../..//console-frames';
import * as keyUtils from '../../../shared/utils/keys';
import Mark from '../../Mark';
const cancelKey = (key: keyUtils.Key): boolean => {
return key.key === 'Esc' || key.key === '[' && Boolean(key.ctrlKey);
};
const globalKey = (key: string): boolean => {
return (/^[A-Z0-9]$/).test(key);
};
export default class MarkComponent {
private store: any;
constructor(store: any) {
this.store = store;
}
// eslint-disable-next-line max-statements
key(key: keyUtils.Key) {
let { mark: markState, setting } = this.store.getState();
let smoothscroll = setting.properties.smoothscroll;
if (!markState.setMode && !markState.jumpMode) {
return false;
}
if (cancelKey(key)) {
this.store.dispatch(markActions.cancel());
return true;
}
if (key.ctrlKey || key.metaKey || key.altKey) {
consoleFrames.postError('Unknown mark');
} else if (globalKey(key.key) && markState.setMode) {
this.doSetGlobal(key);
} else if (globalKey(key.key) && markState.jumpMode) {
this.doJumpGlobal(key);
} else if (markState.setMode) {
this.doSet(key);
} else if (markState.jumpMode) {
this.doJump(markState.marks, key, smoothscroll);
}
this.store.dispatch(markActions.cancel());
return true;
}
doSet(key: keyUtils.Key) {
let { x, y } = scrolls.getScroll();
this.store.dispatch(markActions.setLocal(key.key, x, y));
}
doJump(
marks: { [key: string]: Mark },
key: keyUtils.Key,
smoothscroll: boolean,
) {
if (!marks[key.key]) {
consoleFrames.postError('Mark is not set');
return;
}
let { x, y } = marks[key.key];
scrolls.scrollTo(x, y, smoothscroll);
}
doSetGlobal(key: keyUtils.Key) {
let { x, y } = scrolls.getScroll();
this.store.dispatch(markActions.setGlobal(key.key, x, y));
}
doJumpGlobal(key: keyUtils.Key) {
this.store.dispatch(markActions.jumpGlobal(key.key));
}
}

@ -1,3 +0,0 @@
import CommonComponent from './common';
export default CommonComponent;

@ -1,46 +0,0 @@
import * as findActions from '../../actions/find';
import * as messages from '../../../shared/messages';
import MessageListener from '../../MessageListener';
export default class FindComponent {
private store: any;
constructor(store: any) {
this.store = store;
new MessageListener().onWebMessage(this.onMessage.bind(this));
}
onMessage(message: messages.Message) {
switch (message.type) {
case messages.CONSOLE_ENTER_FIND:
return this.start(message.text);
case messages.FIND_NEXT:
return this.next();
case messages.FIND_PREV:
return this.prev();
}
}
start(text: string) {
let state = this.store.getState().find;
if (text.length === 0) {
return this.store.dispatch(
findActions.next(state.keyword as string, true));
}
return this.store.dispatch(findActions.next(text, true));
}
next() {
let state = this.store.getState().find;
return this.store.dispatch(
findActions.next(state.keyword as string, false));
}
prev() {
let state = this.store.getState().find;
return this.store.dispatch(
findActions.prev(state.keyword as string, false));
}
}

@ -1,166 +0,0 @@
import * as followControllerActions from '../../actions/follow-controller';
import * as messages from '../../../shared/messages';
import MessageListener, { WebMessageSender } from '../../MessageListener';
import HintKeyProducer from '../../hint-key-producer';
const broadcastMessage = (win: Window, message: messages.Message): void => {
let json = JSON.stringify(message);
let frames = [win.self].concat(Array.from(win.frames as any));
frames.forEach(frame => frame.postMessage(json, '*'));
};
export default class FollowController {
private win: Window;
private store: any;
private state: {
enabled?: boolean;
newTab?: boolean;
background?: boolean;
keys?: string,
};
private keys: string[];
private producer: HintKeyProducer | null;
constructor(win: Window, store: any) {
this.win = win;
this.store = store;
this.state = {};
this.keys = [];
this.producer = null;
new MessageListener().onWebMessage(this.onMessage.bind(this));
store.subscribe(() => {
this.update();
});
}
onMessage(message: messages.Message, sender: WebMessageSender) {
switch (message.type) {
case messages.FOLLOW_START:
return this.store.dispatch(
followControllerActions.enable(message.newTab, message.background));
case messages.FOLLOW_RESPONSE_COUNT_TARGETS:
return this.create(message.count, sender);
case messages.FOLLOW_KEY_PRESS:
return this.keyPress(message.key, message.ctrlKey);
}
}
update(): void {
let prevState = this.state;
this.state = this.store.getState().followController;
if (!prevState.enabled && this.state.enabled) {
this.count();
} else if (prevState.enabled && !this.state.enabled) {
this.remove();
} else if (prevState.keys !== this.state.keys) {
this.updateHints();
}
}
updateHints(): void {
let shown = this.keys.filter((key) => {
return key.startsWith(this.state.keys as string);
});
if (shown.length === 1) {
this.activate();
this.store.dispatch(followControllerActions.disable());
}
broadcastMessage(this.win, {
type: messages.FOLLOW_SHOW_HINTS,
keys: this.state.keys as string,
});
}
activate(): void {
broadcastMessage(this.win, {
type: messages.FOLLOW_ACTIVATE,
keys: this.state.keys as string,
});
}
keyPress(key: string, ctrlKey: boolean): boolean {
if (key === '[' && ctrlKey) {
this.store.dispatch(followControllerActions.disable());
return true;
}
switch (key) {
case 'Enter':
this.activate();
this.store.dispatch(followControllerActions.disable());
break;
case 'Esc':
this.store.dispatch(followControllerActions.disable());
break;
case 'Backspace':
case 'Delete':
this.store.dispatch(followControllerActions.backspace());
break;
default:
if (this.hintchars().includes(key)) {
this.store.dispatch(followControllerActions.keyPress(key));
}
break;
}
return true;
}
count() {
this.producer = new HintKeyProducer(this.hintchars());
let doc = this.win.document;
let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth;
let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight;
let frameElements = this.win.document.querySelectorAll('frame,iframe');
this.win.postMessage(JSON.stringify({
type: messages.FOLLOW_REQUEST_COUNT_TARGETS,
viewSize: { width: viewWidth, height: viewHeight },
framePosition: { x: 0, y: 0 },
}), '*');
frameElements.forEach((ele) => {
let { left: frameX, top: frameY } = ele.getBoundingClientRect();
let message = JSON.stringify({
type: messages.FOLLOW_REQUEST_COUNT_TARGETS,
viewSize: { width: viewWidth, height: viewHeight },
framePosition: { x: frameX, y: frameY },
});
if (ele instanceof HTMLFrameElement && ele.contentWindow ||
ele instanceof HTMLIFrameElement && ele.contentWindow) {
ele.contentWindow.postMessage(message, '*');
}
});
}
create(count: number, sender: WebMessageSender) {
let produced = [];
for (let i = 0; i < count; ++i) {
produced.push((this.producer as HintKeyProducer).produce());
}
this.keys = this.keys.concat(produced);
(sender as Window).postMessage(JSON.stringify({
type: messages.FOLLOW_CREATE_HINTS,
keysArray: produced,
newTab: this.state.newTab,
background: this.state.background,
}), '*');
}
remove() {
this.keys = [];
broadcastMessage(this.win, {
type: messages.FOLLOW_REMOVE_HINTS,
});
}
hintchars() {
return this.store.getState().setting.properties.hintchars;
}
}

@ -1,51 +0,0 @@
import CommonComponent from '../common';
import FollowController from './follow-controller';
import FindComponent from './find';
import * as consoleFrames from '../../console-frames';
import * as messages from '../../../shared/messages';
import MessageListener from '../../MessageListener';
import * as scrolls from '../../scrolls';
export default class TopContent {
private win: Window;
private store: any;
constructor(win: Window, store: any) {
this.win = win;
this.store = store;
new CommonComponent(win, store); // eslint-disable-line no-new
new FollowController(win, store); // eslint-disable-line no-new
new FindComponent(store); // eslint-disable-line no-new
// TODO make component
consoleFrames.initialize(this.win.document);
new MessageListener().onWebMessage(this.onWebMessage.bind(this));
new MessageListener().onBackgroundMessage(
this.onBackgroundMessage.bind(this));
}
onWebMessage(message: messages.Message) {
switch (message.type) {
case messages.CONSOLE_UNFOCUS:
this.win.focus();
consoleFrames.blur(window.document);
}
}
onBackgroundMessage(message: messages.Message) {
let addonState = this.store.getState().addon;
switch (message.type) {
case messages.ADDON_ENABLED_QUERY:
return Promise.resolve({
type: messages.ADDON_ENABLED_RESPONSE,
enabled: addonState.enabled,
});
case messages.TAB_SCROLL_TO:
return scrolls.scrollTo(message.x, message.y, false);
}
}
}

@ -1,38 +0,0 @@
import * as messages from '../shared/messages';
const initialize = (doc: Document): HTMLIFrameElement => {
let iframe = doc.createElement('iframe');
iframe.src = browser.runtime.getURL('build/console.html');
iframe.id = 'vimvixen-console-frame';
iframe.className = 'vimvixen-console-frame';
doc.body.append(iframe);
return iframe;
};
const blur = (doc: Document) => {
let ele = doc.getElementById('vimvixen-console-frame') as HTMLIFrameElement;
ele.blur();
};
const postError = (text: string): Promise<any> => {
return browser.runtime.sendMessage({
type: messages.CONSOLE_FRAME_MESSAGE,
message: {
type: messages.CONSOLE_SHOW_ERROR,
text,
},
});
};
const postInfo = (text: string): Promise<any> => {
return browser.runtime.sendMessage({
type: messages.CONSOLE_FRAME_MESSAGE,
message: {
type: messages.CONSOLE_SHOW_INFO,
text,
},
});
};
export { initialize, blur, postError, postInfo };

@ -0,0 +1,19 @@
import * as messages from '../../shared/messages';
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
export default class AddonEnabledController {
private addonEnabledUseCase: AddonEnabledUseCase;
constructor({
addonEnabledUseCase = new AddonEnabledUseCase(),
} = {}) {
this.addonEnabledUseCase = addonEnabledUseCase;
}
getAddonEnabled(
_message: messages.AddonEnabledQueryMessage,
): Promise<boolean> {
let enabled = this.addonEnabledUseCase.getEnabled();
return Promise.resolve(enabled);
}
}

@ -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();
}
}

@ -0,0 +1,24 @@
import * as messages from '../../shared/messages';
import FindUseCase from '../usecases/FindUseCase';
export default class FindController {
private findUseCase: FindUseCase;
constructor({
findUseCase = new FindUseCase(),
} = {}) {
this.findUseCase = findUseCase;
}
async start(m: messages.ConsoleEnterFindMessage): Promise<void> {
await this.findUseCase.startFind(m.text);
}
async next(_: messages.FindNextMessage): Promise<void> {
await this.findUseCase.findNext();
}
async prev(_: messages.FindPrevMessage): Promise<void> {
await this.findUseCase.findPrev();
}
}

@ -0,0 +1,21 @@
import FollowSlaveUseCase from '../usecases/FollowSlaveUseCase';
import Key from '../domains/Key';
export default class FollowKeyController {
private followSlaveUseCase: FollowSlaveUseCase;
constructor({
followSlaveUseCase = new FollowSlaveUseCase(),
} = {}) {
this.followSlaveUseCase = followSlaveUseCase;
}
press(key: Key): boolean {
if (!this.followSlaveUseCase.isFollowMode()) {
return false;
}
this.followSlaveUseCase.sendKey(key);
return true;
}
}

@ -0,0 +1,31 @@
import FollowMasterUseCase from '../usecases/FollowMasterUseCase';
import * as messages from '../../shared/messages';
export default class FollowMasterController {
private followMasterUseCase: FollowMasterUseCase;
constructor({
followMasterUseCase = new FollowMasterUseCase(),
} = {}) {
this.followMasterUseCase = followMasterUseCase;
}
followStart(m: messages.FollowStartMessage): void {
this.followMasterUseCase.startFollow(m.newTab, m.background);
}
responseCountTargets(
m: messages.FollowResponseCountTargetsMessage, sender: Window,
): void {
this.followMasterUseCase.createSlaveHints(m.count, sender);
}
keyPress(message: messages.FollowKeyPressMessage): void {
if (message.key === '[' && message.ctrlKey) {
this.followMasterUseCase.cancelFollow();
} else {
this.followMasterUseCase.enqueue(message.key);
}
}
}

@ -0,0 +1,32 @@
import * as messages from '../../shared/messages';
import FollowSlaveUseCase from '../usecases/FollowSlaveUseCase';
export default class FollowSlaveController {
private usecase: FollowSlaveUseCase;
constructor({
usecase = new FollowSlaveUseCase(),
} = {}) {
this.usecase = usecase;
}
countTargets(m: messages.FollowRequestCountTargetsMessage): void {
this.usecase.countTargets(m.viewSize, m.framePosition);
}
createHints(m: messages.FollowCreateHintsMessage): void {
this.usecase.createHints(m.viewSize, m.framePosition, m.tags);
}
showHints(m: messages.FollowShowHintsMessage): void {
this.usecase.showHints(m.prefix);
}
activate(m: messages.FollowActivateMessage): void {
this.usecase.activate(m.tag, m.newTab, m.background);
}
clear(_m: messages.FollowRemoveHintsMessage) {
this.usecase.clear();
}
}

@ -0,0 +1,148 @@
import * as operations from '../../shared/operations';
import KeymapUseCase from '../usecases/KeymapUseCase';
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
import FindSlaveUseCase from '../usecases/FindSlaveUseCase';
import ScrollUseCase from '../usecases/ScrollUseCase';
import NavigateUseCase from '../usecases/NavigateUseCase';
import FocusUseCase from '../usecases/FocusUseCase';
import ClipboardUseCase from '../usecases/ClipboardUseCase';
import BackgroundClient from '../client/BackgroundClient';
import MarkKeyyUseCase from '../usecases/MarkKeyUseCase';
import FollowMasterClient, { FollowMasterClientImpl }
from '../client/FollowMasterClient';
import Key from '../domains/Key';
export default class KeymapController {
private keymapUseCase: KeymapUseCase;
private addonEnabledUseCase: AddonEnabledUseCase;
private findSlaveUseCase: FindSlaveUseCase;
private scrollUseCase: ScrollUseCase;
private navigateUseCase: NavigateUseCase;
private focusUseCase: FocusUseCase;
private clipbaordUseCase: ClipboardUseCase;
private backgroundClient: BackgroundClient;
private markKeyUseCase: MarkKeyyUseCase;
private followMasterClient: FollowMasterClient;
constructor({
keymapUseCase = new KeymapUseCase(),
addonEnabledUseCase = new AddonEnabledUseCase(),
findSlaveUseCase = new FindSlaveUseCase(),
scrollUseCase = new ScrollUseCase(),
navigateUseCase = new NavigateUseCase(),
focusUseCase = new FocusUseCase(),
clipbaordUseCase = new ClipboardUseCase(),
backgroundClient = new BackgroundClient(),
markKeyUseCase = new MarkKeyyUseCase(),
followMasterClient = new FollowMasterClientImpl(window.top),
} = {}) {
this.keymapUseCase = keymapUseCase;
this.addonEnabledUseCase = addonEnabledUseCase;
this.findSlaveUseCase = findSlaveUseCase;
this.scrollUseCase = scrollUseCase;
this.navigateUseCase = navigateUseCase;
this.focusUseCase = focusUseCase;
this.clipbaordUseCase = clipbaordUseCase;
this.backgroundClient = backgroundClient;
this.markKeyUseCase = markKeyUseCase;
this.followMasterClient = followMasterClient;
}
// eslint-disable-next-line complexity, max-lines-per-function
press(key: Key): boolean {
let op = this.keymapUseCase.nextOp(key);
if (op === null) {
return false;
}
// do not await due to return a boolean immediately
switch (op.type) {
case operations.ADDON_ENABLE:
this.addonEnabledUseCase.enable();
break;
case operations.ADDON_DISABLE:
this.addonEnabledUseCase.disable();
break;
case operations.ADDON_TOGGLE_ENABLED:
this.addonEnabledUseCase.toggle();
break;
case operations.FIND_NEXT:
this.findSlaveUseCase.findNext();
break;
case operations.FIND_PREV:
this.findSlaveUseCase.findPrev();
break;
case operations.SCROLL_VERTICALLY:
this.scrollUseCase.scrollVertically(op.count);
break;
case operations.SCROLL_HORIZONALLY:
this.scrollUseCase.scrollHorizonally(op.count);
break;
case operations.SCROLL_PAGES:
this.scrollUseCase.scrollPages(op.count);
break;
case operations.SCROLL_TOP:
this.scrollUseCase.scrollToTop();
break;
case operations.SCROLL_BOTTOM:
this.scrollUseCase.scrollToBottom();
break;
case operations.SCROLL_HOME:
this.scrollUseCase.scrollToHome();
break;
case operations.SCROLL_END:
this.scrollUseCase.scrollToEnd();
break;
case operations.FOLLOW_START:
this.followMasterClient.startFollow(op.newTab, op.background);
break;
case operations.MARK_SET_PREFIX:
this.markKeyUseCase.enableSetMode();
break;
case operations.MARK_JUMP_PREFIX:
this.markKeyUseCase.enableJumpMode();
break;
case operations.NAVIGATE_HISTORY_PREV:
this.navigateUseCase.openHistoryPrev();
break;
case operations.NAVIGATE_HISTORY_NEXT:
this.navigateUseCase.openHistoryNext();
break;
case operations.NAVIGATE_LINK_PREV:
this.navigateUseCase.openLinkPrev();
break;
case operations.NAVIGATE_LINK_NEXT:
this.navigateUseCase.openLinkNext();
break;
case operations.NAVIGATE_PARENT:
this.navigateUseCase.openParent();
break;
case operations.NAVIGATE_ROOT:
this.navigateUseCase.openRoot();
break;
case operations.FOCUS_INPUT:
this.focusUseCase.focusFirstInput();
break;
case operations.URLS_YANK:
this.clipbaordUseCase.yankCurrentURL();
break;
case operations.URLS_PASTE:
this.clipbaordUseCase.openOrSearch(
op.newTab ? op.newTab : false,
);
break;
default:
this.backgroundClient.execBackgroundOp(op);
}
return true;
}
}

@ -0,0 +1,16 @@
import * as messages from '../../shared/messages';
import MarkUseCase from '../usecases/MarkUseCase';
export default class MarkController {
private markUseCase: MarkUseCase;
constructor({
markUseCase = new MarkUseCase(),
} = {}) {
this.markUseCase = markUseCase;
}
scrollTo(message: messages.TabScrollToMessage) {
this.markUseCase.scroll(message.x, message.y);
}
}

@ -0,0 +1,31 @@
import MarkUseCase from '../usecases/MarkUseCase';
import MarkKeyyUseCase from '../usecases/MarkKeyUseCase';
import Key from '../domains/Key';
export default class MarkKeyController {
private markUseCase: MarkUseCase;
private markKeyUseCase: MarkKeyyUseCase;
constructor({
markUseCase = new MarkUseCase(),
markKeyUseCase = new MarkKeyyUseCase(),
} = {}) {
this.markUseCase = markUseCase;
this.markKeyUseCase = markKeyUseCase;
}
press(key: Key): boolean {
if (this.markKeyUseCase.isSetMode()) {
this.markUseCase.set(key.key);
this.markKeyUseCase.disableSetMode();
return true;
}
if (this.markKeyUseCase.isJumpMode()) {
this.markUseCase.jump(key.key);
this.markKeyUseCase.disableJumpMode();
return true;
}
return false;
}
}

@ -0,0 +1,41 @@
import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
import SettingUseCase from '../usecases/SettingUseCase';
import * as blacklists from '../../shared/blacklists';
import * as messages from '../../shared/messages';
export default class SettingController {
private addonEnabledUseCase: AddonEnabledUseCase;
private settingUseCase: SettingUseCase;
constructor({
addonEnabledUseCase = new AddonEnabledUseCase(),
settingUseCase = new SettingUseCase(),
} = {}) {
this.addonEnabledUseCase = addonEnabledUseCase;
this.settingUseCase = settingUseCase;
}
async initSettings(): Promise<void> {
try {
let current = await this.settingUseCase.reload();
let disabled = blacklists.includes(
current.blacklist, window.location.href,
);
if (disabled) {
this.addonEnabledUseCase.disable();
} else {
this.addonEnabledUseCase.enable();
}
} catch (e) {
// Sometime sendMessage fails when background script is not ready.
console.warn(e);
setTimeout(() => this.initSettings(), 500);
}
}
async reloadSettings(_message: messages.Message): Promise<void> {
await this.settingUseCase.reload();
}
}

@ -1,9 +1,11 @@
export interface Key {
key: string;
shiftKey: boolean | undefined;
ctrlKey: boolean | undefined;
altKey: boolean | undefined;
metaKey: boolean | undefined;
export default interface Key {
key: string;
shiftKey?: boolean;
ctrlKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
// eslint-disable-next-line semi
}
const modifiedKeyName = (name: string): string => {
@ -18,7 +20,7 @@ const modifiedKeyName = (name: string): string => {
return name;
};
const fromKeyboardEvent = (e: KeyboardEvent): Key => {
export const fromKeyboardEvent = (e: KeyboardEvent): Key => {
let key = modifiedKeyName(e.key);
let shift = e.shiftKey;
if (key.length === 1 && key.toUpperCase() === key.toLowerCase()) {
@ -36,7 +38,7 @@ const fromKeyboardEvent = (e: KeyboardEvent): Key => {
};
};
const fromMapKey = (key: string): Key => {
export const fromMapKey = (key: string): Key => {
if (key.startsWith('<') && key.endsWith('>')) {
let inner = key.slice(1, -1);
let shift = inner.includes('S-');
@ -63,37 +65,10 @@ const fromMapKey = (key: string): Key => {
};
};
const fromMapKeys = (keys: string): Key[] => {
const fromMapKeysRecursive = (
remainings: string, mappedKeys: Key[],
): Key[] => {
if (remainings.length === 0) {
return mappedKeys;
}
let nextPos = 1;
if (remainings.startsWith('<')) {
let ltPos = remainings.indexOf('>');
if (ltPos > 0) {
nextPos = ltPos + 1;
}
}
return fromMapKeysRecursive(
remainings.slice(nextPos),
mappedKeys.concat([fromMapKey(remainings.slice(0, nextPos))])
);
};
return fromMapKeysRecursive(keys, []);
};
const equals = (e1: Key, e2: Key): boolean => {
export const equals = (e1: Key, e2: Key): boolean => {
return e1.key === e2.key &&
e1.ctrlKey === e2.ctrlKey &&
e1.metaKey === e2.metaKey &&
e1.altKey === e2.altKey &&
e1.shiftKey === e2.shiftKey;
};
export { fromKeyboardEvent, fromMapKey, fromMapKeys, equals };

@ -0,0 +1,64 @@
import Key, * as keyUtils from './Key';
export default class KeySequence {
private keys: Key[];
private constructor(keys: Key[]) {
this.keys = keys;
}
static from(keys: Key[]): KeySequence {
return new KeySequence(keys);
}
push(key: Key): number {
return this.keys.push(key);
}
length(): number {
return this.keys.length;
}
startsWith(o: KeySequence): boolean {
if (this.keys.length < o.keys.length) {
return false;
}
for (let i = 0; i < o.keys.length; ++i) {
if (!keyUtils.equals(this.keys[i], o.keys[i])) {
return false;
}
}
return true;
}
getKeyArray(): Key[] {
return this.keys;
}
}
export const fromMapKeys = (keys: string): KeySequence => {
const fromMapKeysRecursive = (
remainings: string, mappedKeys: Key[],
): Key[] => {
if (remainings.length === 0) {
return mappedKeys;
}
let nextPos = 1;
if (remainings.startsWith('<')) {
let ltPos = remainings.indexOf('>');
if (ltPos > 0) {
nextPos = ltPos + 1;
}
}
return fromMapKeysRecursive(
remainings.slice(nextPos),
mappedKeys.concat([keyUtils.fromMapKey(remainings.slice(0, nextPos))])
);
};
let data = fromMapKeysRecursive(keys, []);
return KeySequence.from(data);
};

@ -1,15 +0,0 @@
import * as doms from '../shared/utils/dom';
const focusInput = (): void => {
let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url'];
let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(',');
let targets = window.document.querySelectorAll(inputSelector + ',textarea');
let target = Array.from(targets).find(doms.isVisible);
if (target instanceof HTMLInputElement) {
target.focus();
} else if (target instanceof HTMLTextAreaElement) {
target.focus();
}
};
export { focusInput };

@ -1,16 +1,16 @@
import TopContentComponent from './components/top-content';
import FrameContentComponent from './components/frame-content';
import { ConsoleFramePresenterImpl } from './presenters/ConsoleFramePresenter';
import consoleFrameStyle from './site-style';
import { newStore } from './store';
const store = newStore();
import * as routes from './routes';
if (window.self === window.top) {
new TopContentComponent(window, store); // eslint-disable-line no-new
} else {
new FrameContentComponent(window, store); // eslint-disable-line no-new
routes.routeMasterComponents();
new ConsoleFramePresenterImpl().initialize();
}
routes.routeComponents();
let style = window.document.createElement('style');
style.textContent = consoleFrameStyle;
window.document.head.appendChild(style);

@ -1,83 +0,0 @@
const REL_PATTERN: {[key: string]: RegExp} = {
prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i,
next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i,
};
// Return the last element in the document matching the supplied selector
// and the optional filter, or null if there are no matches.
// eslint-disable-next-line func-style
function selectLast<E extends Element>(
win: Window,
selector: string,
filter?: (e: E) => boolean,
): E | null {
let nodes = Array.from(
win.document.querySelectorAll(selector) as NodeListOf<E>
);
if (filter) {
nodes = nodes.filter(filter);
}
return nodes.length ? nodes[nodes.length - 1] : null;
}
const historyPrev = (win: Window): void => {
win.history.back();
};
const historyNext = (win: Window): void => {
win.history.forward();
};
// Code common to linkPrev and linkNext which navigates to the specified page.
const linkRel = (win: Window, rel: string): void => {
let link = selectLast<HTMLLinkElement>(win, `link[rel~=${rel}][href]`);
if (link) {
win.location.href = link.href;
return;
}
const pattern = REL_PATTERN[rel];
let a = selectLast<HTMLAnchorElement>(win, `a[rel~=${rel}][href]`) ||
// `innerText` is much slower than `textContent`, but produces much better
// (i.e. less unexpected) results
selectLast(win, 'a[href]', lnk => pattern.test(lnk.innerText));
if (a) {
a.click();
}
};
const linkPrev = (win: Window): void => {
linkRel(win, 'prev');
};
const linkNext = (win: Window): void => {
linkRel(win, 'next');
};
const parent = (win: Window): void => {
const loc = win.location;
if (loc.hash !== '') {
loc.hash = '';
return;
} else if (loc.search !== '') {
loc.search = '';
return;
}
const basenamePattern = /\/[^/]+$/;
const lastDirPattern = /\/[^/]+\/$/;
if (basenamePattern.test(loc.pathname)) {
loc.pathname = loc.pathname.replace(basenamePattern, '/');
} else if (lastDirPattern.test(loc.pathname)) {
loc.pathname = loc.pathname.replace(lastDirPattern, '/');
}
};
const root = (win: Window): void => {
win.location.href = win.location.origin;
};
export { historyPrev, historyNext, linkPrev, linkNext, parent, root };

@ -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();
}
}

@ -0,0 +1,52 @@
export default interface FindPresenter {
find(keyword: string, backwards: boolean): boolean;
clearSelection(): void;
// eslint-disable-next-line semi
}
// window.find(aString, aCaseSensitive, aBackwards, aWrapAround,
// aWholeWord, aSearchInFrames);
//
// NOTE: window.find is not standard API
// https://developer.mozilla.org/en-US/docs/Web/API/Window/find
interface MyWindow extends Window {
find(
aString: string,
aCaseSensitive?: boolean,
aBackwards?: boolean,
aWrapAround?: boolean,
aWholeWord?: boolean,
aSearchInFrames?: boolean,
aShowDialog?: boolean): boolean;
}
// eslint-disable-next-line no-var, vars-on-top, init-declarations
declare var window: MyWindow;
export class FindPresenterImpl implements FindPresenter {
find(keyword: string, backwards: boolean): boolean {
let caseSensitive = false;
let wrapScan = true;
// NOTE: aWholeWord dows not implemented, and aSearchInFrames does not work
// because of same origin policy
let found = window.find(keyword, caseSensitive, backwards, wrapScan);
if (found) {
return found;
}
this.clearSelection();
return window.find(keyword, caseSensitive, backwards, wrapScan);
}
clearSelection(): void {
let sel = window.getSelection();
if (sel) {
sel.removeAllRanges();
}
}
}

@ -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;
}
}

@ -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;
}
}

@ -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();
}
}
}
}

@ -0,0 +1,98 @@
export default interface NavigationPresenter {
openHistoryPrev(): void;
openHistoryNext(): void;
openLinkPrev(): void;
openLinkNext(): void;
openParent(): void;
openRoot(): void;
// eslint-disable-next-line semi
}
const REL_PATTERN: {[key: string]: RegExp} = {
prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i,
next: /^(?:next|newer)\b|\u203a|\u2192|\xbb|\u226b|>>/i,
};
// Return the last element in the document matching the supplied selector
// and the optional filter, or null if there are no matches.
// eslint-disable-next-line func-style
function selectLast<E extends Element>(
selector: string,
filter?: (e: E) => boolean,
): E | null {
let nodes = Array.from(
window.document.querySelectorAll(selector) as NodeListOf<E>
);
if (filter) {
nodes = nodes.filter(filter);
}
return nodes.length ? nodes[nodes.length - 1] : null;
}
export class NavigationPresenterImpl implements NavigationPresenter {
openHistoryPrev(): void {
window.history.back();
}
openHistoryNext(): void {
window.history.forward();
}
openLinkPrev(): void {
this.linkRel('prev');
}
openLinkNext(): void {
this.linkRel('next');
}
openParent(): void {
const loc = window.location;
if (loc.hash !== '') {
loc.hash = '';
return;
} else if (loc.search !== '') {
loc.search = '';
return;
}
const basenamePattern = /\/[^/]+$/;
const lastDirPattern = /\/[^/]+\/$/;
if (basenamePattern.test(loc.pathname)) {
loc.pathname = loc.pathname.replace(basenamePattern, '/');
} else if (lastDirPattern.test(loc.pathname)) {
loc.pathname = loc.pathname.replace(lastDirPattern, '/');
}
}
openRoot(): void {
window.location.href = window.location.origin;
}
// Code common to linkPrev and linkNext which navigates to the specified page.
private linkRel(rel: 'prev' | 'next'): void {
let link = selectLast<HTMLLinkElement>(`link[rel~=${rel}][href]`);
if (link) {
window.location.href = link.href;
return;
}
const pattern = REL_PATTERN[rel];
let a = selectLast<HTMLAnchorElement>(`a[rel~=${rel}][href]`) ||
// `innerText` is much slower than `textContent`, but produces much better
// (i.e. less unexpected) results
selectLast('a[href]', lnk => pattern.test(lnk.innerText));
if (a) {
a.click();
}
}
}

@ -1,4 +1,4 @@
import * as doms from '../shared/utils/dom';
import * as doms from '../../shared/utils/dom';
const SCROLL_DELTA_X = 64;
const SCROLL_DELTA_Y = 64;
@ -94,75 +94,86 @@ class Scroller {
}
}
const getScroll = () => {
let target = scrollTarget();
return { x: target.scrollLeft, y: target.scrollTop };
};
export type Point = { x: number, y: number };
export default interface ScrollPresenter {
getScroll(): Point;
scrollVertically(amount: number, smooth: boolean): void;
scrollHorizonally(amount: number, smooth: boolean): void;
scrollPages(amount: number, smooth: boolean): void;
scrollTo(x: number, y: number, smooth: boolean): void;
scrollToTop(smooth: boolean): void;
scrollToBottom(smooth: boolean): void;
scrollToHome(smooth: boolean): void;
scrollToEnd(smooth: boolean): void;
// eslint-disable-next-line semi
}
const scrollVertically = (count: number, smooth: boolean): void => {
let target = scrollTarget();
let delta = SCROLL_DELTA_Y * count;
if (scrolling) {
delta = SCROLL_DELTA_Y * count * 4;
export class ScrollPresenterImpl {
getScroll(): Point {
let target = scrollTarget();
return { x: target.scrollLeft, y: target.scrollTop };
}
new Scroller(target, smooth).scrollBy(0, delta);
};
const scrollHorizonally = (count: number, smooth: boolean): void => {
let target = scrollTarget();
let delta = SCROLL_DELTA_X * count;
if (scrolling) {
delta = SCROLL_DELTA_X * count * 4;
scrollVertically(count: number, smooth: boolean): void {
let target = scrollTarget();
let delta = SCROLL_DELTA_Y * count;
if (scrolling) {
delta = SCROLL_DELTA_Y * count * 4;
}
new Scroller(target, smooth).scrollBy(0, delta);
}
new Scroller(target, smooth).scrollBy(delta, 0);
};
const scrollPages = (count: number, smooth: boolean): void => {
let target = scrollTarget();
let height = target.clientHeight;
let delta = height * count;
if (scrolling) {
delta = height * count;
scrollHorizonally(count: number, smooth: boolean): void {
let target = scrollTarget();
let delta = SCROLL_DELTA_X * count;
if (scrolling) {
delta = SCROLL_DELTA_X * count * 4;
}
new Scroller(target, smooth).scrollBy(delta, 0);
}
new Scroller(target, smooth).scrollBy(0, delta);
};
const scrollTo = (x: number, y: number, smooth: boolean): void => {
let target = scrollTarget();
new Scroller(target, smooth).scrollTo(x, y);
};
scrollPages(count: number, smooth: boolean): void {
let target = scrollTarget();
let height = target.clientHeight;
let delta = height * count;
if (scrolling) {
delta = height * count;
}
new Scroller(target, smooth).scrollBy(0, delta);
}
const scrollToTop = (smooth: boolean): void => {
let target = scrollTarget();
let x = target.scrollLeft;
let y = 0;
new Scroller(target, smooth).scrollTo(x, y);
};
scrollTo(x: number, y: number, smooth: boolean): void {
let target = scrollTarget();
new Scroller(target, smooth).scrollTo(x, y);
}
const scrollToBottom = (smooth: boolean): void => {
let target = scrollTarget();
let x = target.scrollLeft;
let y = target.scrollHeight;
new Scroller(target, smooth).scrollTo(x, y);
};
scrollToTop(smooth: boolean): void {
let target = scrollTarget();
let x = target.scrollLeft;
let y = 0;
new Scroller(target, smooth).scrollTo(x, y);
}
const scrollToHome = (smooth: boolean): void => {
let target = scrollTarget();
let x = 0;
let y = target.scrollTop;
new Scroller(target, smooth).scrollTo(x, y);
};
scrollToBottom(smooth: boolean): void {
let target = scrollTarget();
let x = target.scrollLeft;
let y = target.scrollHeight;
new Scroller(target, smooth).scrollTo(x, y);
}
const scrollToEnd = (smooth: boolean): void => {
let target = scrollTarget();
let x = target.scrollWidth;
let y = target.scrollTop;
new Scroller(target, smooth).scrollTo(x, y);
};
scrollToHome(smooth: boolean): void {
let target = scrollTarget();
let x = 0;
let y = target.scrollTop;
new Scroller(target, smooth).scrollTo(x, y);
}
export {
getScroll,
scrollVertically, scrollHorizonally, scrollPages,
scrollTo,
scrollToTop, scrollToBottom, scrollToHome, scrollToEnd
};
scrollToEnd(smooth: boolean): void {
let target = scrollTarget();
let x = target.scrollWidth;
let y = target.scrollTop;
new Scroller(target, smooth).scrollTo(x, y);
}
}

@ -1,22 +0,0 @@
import * as actions from '../actions';
export interface State {
enabled: boolean;
}
const defaultState: State = {
enabled: true,
};
export default function reducer(
state: State = defaultState,
action: actions.AddonAction,
): State {
switch (action.type) {
case actions.ADDON_SET_ENABLED:
return { ...state,
enabled: action.enabled, };
default:
return state;
}
}

@ -1,25 +0,0 @@
import * as actions from '../actions';
export interface State {
keyword: string | null;
found: boolean;
}
const defaultState: State = {
keyword: null,
found: false,
};
export default function reducer(
state: State = defaultState,
action: actions.FindAction,
): State {
switch (action.type) {
case actions.FIND_SET_KEYWORD:
return { ...state,
keyword: action.keyword,
found: action.found, };
default:
return state;
}
}

@ -1,40 +0,0 @@
import * as actions from '../actions';
export interface State {
enabled: boolean;
newTab: boolean;
background: boolean;
keys: string,
}
const defaultState: State = {
enabled: false,
newTab: false,
background: false,
keys: '',
};
export default function reducer(
state: State = defaultState,
action: actions.FollowAction,
): State {
switch (action.type) {
case actions.FOLLOW_CONTROLLER_ENABLE:
return { ...state,
enabled: true,
newTab: action.newTab,
background: action.background,
keys: '', };
case actions.FOLLOW_CONTROLLER_DISABLE:
return { ...state,
enabled: false, };
case actions.FOLLOW_CONTROLLER_KEY_PRESS:
return { ...state,
keys: state.keys + action.key, };
case actions.FOLLOW_CONTROLLER_BACKSPACE:
return { ...state,
keys: state.keys.slice(0, -1), };
default:
return state;
}
}

@ -1,21 +0,0 @@
import { combineReducers } from 'redux';
import addon, { State as AddonState } from './addon';
import find, { State as FindState } from './find';
import setting, { State as SettingState } from './setting';
import input, { State as InputState } from './input';
import followController, { State as FollowControllerState }
from './follow-controller';
import mark, { State as MarkState } from './mark';
export interface State {
addon: AddonState;
find: FindState;
setting: SettingState;
input: InputState;
followController: FollowControllerState;
mark: MarkState;
}
export default combineReducers({
addon, find, setting, input, followController, mark,
});

@ -1,26 +0,0 @@
import * as actions from '../actions';
import * as keyUtils from '../../shared/utils/keys';
export interface State {
keys: keyUtils.Key[],
}
const defaultState: State = {
keys: []
};
export default function reducer(
state: State = defaultState,
action: actions.InputAction,
): State {
switch (action.type) {
case actions.INPUT_KEY_PRESS:
return { ...state,
keys: state.keys.concat([action.key]), };
case actions.INPUT_CLEAR_KEYS:
return { ...state,
keys: [], };
default:
return state;
}
}

@ -1,35 +0,0 @@
import Mark from '../Mark';
import * as actions from '../actions';
export interface State {
setMode: boolean;
jumpMode: boolean;
marks: { [key: string]: Mark };
}
const defaultState: State = {
setMode: false,
jumpMode: false,
marks: {},
};
export default function reducer(
state: State = defaultState,
action: actions.MarkAction,
): State {
switch (action.type) {
case actions.MARK_START_SET:
return { ...state, setMode: true };
case actions.MARK_START_JUMP:
return { ...state, jumpMode: true };
case actions.MARK_CANCEL:
return { ...state, setMode: false, jumpMode: false };
case actions.MARK_SET_LOCAL: {
let marks = { ...state.marks };
marks[action.key] = { x: action.x, y: action.y };
return { ...state, setMode: false, marks };
}
default:
return state;
}
}

@ -1,40 +0,0 @@
import * as actions from '../actions';
import * as keyUtils from '../../shared/utils/keys';
import * as operations from '../../shared/operations';
import { Search, Properties, DefaultSetting } from '../../shared/Settings';
export interface State {
keymaps: { key: keyUtils.Key[], op: operations.Operation }[];
search: Search;
properties: Properties;
}
// defaultState does not refer due to the state is load from
// background on load.
const defaultState: State = {
keymaps: [],
search: DefaultSetting.search,
properties: DefaultSetting.properties,
};
export default function reducer(
state: State = defaultState,
action: actions.SettingAction,
): State {
switch (action.type) {
case actions.SETTING_SET:
return {
keymaps: Object.entries(action.settings.keymaps).map((entry) => {
return {
key: keyUtils.fromMapKeys(entry[0]),
op: entry[1],
};
}),
properties: action.settings.properties,
search: action.settings.search,
};
default:
return state;
}
}

@ -0,0 +1,19 @@
let enabled: boolean = false;
export default interface AddonEnabledRepository {
set(on: boolean): void;
get(): boolean;
// eslint-disable-next-line semi
}
export class AddonEnabledRepositoryImpl implements AddonEnabledRepository {
set(on: boolean): void {
enabled = on;
}
get(): boolean {
return enabled;
}
}

@ -0,0 +1,46 @@
export default interface ClipboardRepository {
read(): string;
write(text: string): void;
// eslint-disable-next-line semi
}
export class ClipboardRepositoryImpl {
read(): string {
let textarea = window.document.createElement('textarea');
window.document.body.append(textarea);
textarea.style.position = 'fixed';
textarea.style.top = '-100px';
textarea.contentEditable = 'true';
textarea.focus();
let ok = window.document.execCommand('paste');
let value = textarea.textContent!!;
textarea.remove();
if (!ok) {
throw new Error('failed to access clipbaord');
}
return value;
}
write(text: string): void {
let input = window.document.createElement('input');
window.document.body.append(input);
input.style.position = 'fixed';
input.style.top = '-100px';
input.value = text;
input.select();
let ok = window.document.execCommand('copy');
input.remove();
if (!ok) {
throw new Error('failed to access clipbaord');
}
}
}

@ -0,0 +1,19 @@
export default interface FindRepository {
getLastKeyword(): string | null;
setLastKeyword(keyword: string): void;
// eslint-disable-next-line semi
}
let current: string | null = null;
export class FindRepositoryImpl implements FindRepository {
getLastKeyword(): string | null {
return current;
}
setLastKeyword(keyword: string): void {
current = keyword;
}
}

@ -0,0 +1,35 @@
export default interface FollowKeyRepository {
getKeys(): string[];
pushKey(key: string): void;
popKey(): void;
clearKeys(): void;
// eslint-disable-next-line semi
}
const current: {
keys: string[];
} = {
keys: [],
};
export class FollowKeyRepositoryImpl implements FollowKeyRepository {
getKeys(): string[] {
return current.keys;
}
pushKey(key: string): void {
current.keys.push(key);
}
popKey(): void {
current.keys.pop();
}
clearKeys(): void {
current.keys = [];
}
}

@ -0,0 +1,59 @@
export default interface FollowMasterRepository {
setCurrentFollowMode(newTab: boolean, background: boolean): void;
getTags(): string[];
getTagsByPrefix(prefix: string): string[];
addTag(tag: string): void;
clearTags(): void;
getCurrentNewTabMode(): boolean;
getCurrentBackgroundMode(): boolean;
// eslint-disable-next-line semi
}
const current: {
newTab: boolean;
background: boolean;
tags: string[];
} = {
newTab: false,
background: false,
tags: [],
};
export class FollowMasterRepositoryImpl implements FollowMasterRepository {
setCurrentFollowMode(newTab: boolean, background: boolean): void {
current.newTab = newTab;
current.background = background;
}
getTags(): string[] {
return current.tags;
}
getTagsByPrefix(prefix: string): string[] {
return current.tags.filter(t => t.startsWith(prefix));
}
addTag(tag: string): void {
current.tags.push(tag);
}
clearTags(): void {
current.tags = [];
}
getCurrentNewTabMode(): boolean {
return current.newTab;
}
getCurrentBackgroundMode(): boolean {
return current.background;
}
}

@ -0,0 +1,31 @@
export default interface FollowSlaveRepository {
enableFollowMode(): void;
disableFollowMode(): void;
isFollowMode(): boolean;
// eslint-disable-next-line semi
}
const current: {
enabled: boolean;
} = {
enabled: false,
};
export class FollowSlaveRepositoryImpl implements FollowSlaveRepository {
enableFollowMode(): void {
current.enabled = true;
}
disableFollowMode(): void {
current.enabled = false;
}
isFollowMode(): boolean {
return current.enabled;
}
}

@ -0,0 +1,24 @@
import Key from '../domains/Key';
import KeySequence from '../domains/KeySequence';
export default interface KeymapRepository {
enqueueKey(key: Key): KeySequence;
clear(): void;
// eslint-disable-next-line semi
}
let current: KeySequence = KeySequence.from([]);
export class KeymapRepositoryImpl {
enqueueKey(key: Key): KeySequence {
current.push(key);
return current;
}
clear(): void {
current = KeySequence.from([]);
}
}

@ -0,0 +1,52 @@
export default interface MarkKeyRepository {
isSetMode(): boolean;
enableSetMode(): void;
disabeSetMode(): void;
isJumpMode(): boolean;
enableJumpMode(): void;
disabeJumpMode(): void;
// eslint-disable-next-line semi
}
interface Mode {
setMode: boolean;
jumpMode: boolean;
}
let current: Mode = {
setMode: false,
jumpMode: false,
};
export class MarkKeyRepositoryImpl implements MarkKeyRepository {
isSetMode(): boolean {
return current.setMode;
}
enableSetMode(): void {
current.setMode = true;
}
disabeSetMode(): void {
current.setMode = false;
}
isJumpMode(): boolean {
return current.jumpMode;
}
enableJumpMode(): void {
current.jumpMode = true;
}
disabeJumpMode(): void {
current.jumpMode = false;
}
}

@ -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 };
}
}

@ -0,0 +1,21 @@
import Settings, { DefaultSetting } from '../../shared/Settings';
let current: Settings = DefaultSetting;
export default interface SettingRepository {
set(setting: Settings): void;
get(): Settings;
// eslint-disable-next-line semi
}
export class SettingRepositoryImpl implements SettingRepository {
set(setting: Settings): void {
current = setting;
}
get(): Settings {
return current;
}
}

@ -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;
});
};

@ -1,8 +0,0 @@
import promise from 'redux-promise';
import reducers from '../reducers';
import { createStore, applyMiddleware } from 'redux';
export const newStore = () => createStore(
reducers,
applyMiddleware(promise),
);

@ -1,41 +0,0 @@
import * as messages from '../shared/messages';
import * as urls from '../shared/urls';
import { Search } from '../shared/Settings';
const yank = (win: Window) => {
let input = win.document.createElement('input');
win.document.body.append(input);
input.style.position = 'fixed';
input.style.top = '-100px';
input.value = win.location.href;
input.select();
win.document.execCommand('copy');
input.remove();
};
const paste = (win: Window, newTab: boolean, search: Search) => {
let textarea = win.document.createElement('textarea');
win.document.body.append(textarea);
textarea.style.position = 'fixed';
textarea.style.top = '-100px';
textarea.contentEditable = 'true';
textarea.focus();
if (win.document.execCommand('paste')) {
let value = textarea.textContent as string;
let url = urls.searchUrl(value, search);
browser.runtime.sendMessage({
type: messages.OPEN_URL,
url,
newTab,
});
}
textarea.remove();
};
export { yank, paste };

@ -0,0 +1,40 @@
import AddonIndicatorClient, { AddonIndicatorClientImpl }
from '../client/AddonIndicatorClient';
import AddonEnabledRepository, { AddonEnabledRepositoryImpl }
from '../repositories/AddonEnabledRepository';
export default class AddonEnabledUseCase {
private indicator: AddonIndicatorClient;
private repository: AddonEnabledRepository;
constructor({
indicator = new AddonIndicatorClientImpl(),
repository = new AddonEnabledRepositoryImpl(),
} = {}) {
this.indicator = indicator;
this.repository = repository;
}
async enable(): Promise<void> {
await this.setEnabled(true);
}
async disable(): Promise<void> {
await this.setEnabled(false);
}
async toggle(): Promise<void> {
let current = this.repository.get();
await this.setEnabled(!current);
}
getEnabled(): boolean {
return this.repository.get();
}
private async setEnabled(on: boolean): Promise<void> {
this.repository.set(on);
await this.indicator.setEnabled(on);
}
}

@ -0,0 +1,44 @@
import * as urls from '../../shared/urls';
import ClipboardRepository, { ClipboardRepositoryImpl }
from '../repositories/ClipboardRepository';
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
import TabsClient, { TabsClientImpl }
from '../client/TabsClient';
import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient';
export default class ClipboardUseCase {
private repository: ClipboardRepository;
private settingRepository: SettingRepository;
private client: TabsClient;
private consoleClient: ConsoleClient;
constructor({
repository = new ClipboardRepositoryImpl(),
settingRepository = new SettingRepositoryImpl(),
client = new TabsClientImpl(),
consoleClient = new ConsoleClientImpl(),
} = {}) {
this.repository = repository;
this.settingRepository = settingRepository;
this.client = client;
this.consoleClient = consoleClient;
}
async yankCurrentURL(): Promise<string> {
let url = window.location.href;
this.repository.write(url);
await this.consoleClient.info('Yanked ' + url);
return Promise.resolve(url);
}
async openOrSearch(newTab: boolean): Promise<void> {
let search = this.settingRepository.get().search;
let text = this.repository.read();
let url = urls.searchUrl(text, search);
await this.client.openUrl(url, newTab);
}
}

@ -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();
}
}

@ -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();
}
}

@ -0,0 +1,81 @@
import FindPresenter, { FindPresenterImpl } from '../presenters/FindPresenter';
import FindRepository, { FindRepositoryImpl }
from '../repositories/FindRepository';
import FindClient, { FindClientImpl } from '../client/FindClient';
import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient';
export default class FindUseCase {
private presenter: FindPresenter;
private repository: FindRepository;
private client: FindClient;
private consoleClient: ConsoleClient;
constructor({
presenter = new FindPresenterImpl() as FindPresenter,
repository = new FindRepositoryImpl(),
client = new FindClientImpl(),
consoleClient = new ConsoleClientImpl(),
} = {}) {
this.presenter = presenter;
this.repository = repository;
this.client = client;
this.consoleClient = consoleClient;
}
async startFind(keyword?: string): Promise<void> {
this.presenter.clearSelection();
if (keyword) {
this.saveKeyword(keyword);
} else {
let lastKeyword = await this.getKeyword();
if (!lastKeyword) {
return this.showNoLastKeywordError();
}
this.saveKeyword(lastKeyword);
}
return this.findNext();
}
findNext(): Promise<void> {
return this.findNextPrev(false);
}
findPrev(): Promise<void> {
return this.findNextPrev(true);
}
private async findNextPrev(
backwards: boolean,
): Promise<void> {
let keyword = await this.getKeyword();
if (!keyword) {
return this.showNoLastKeywordError();
}
let found = this.presenter.find(keyword, backwards);
if (found) {
this.consoleClient.info('Pattern found: ' + keyword);
} else {
this.consoleClient.error('Pattern not found: ' + keyword);
}
}
private async getKeyword(): Promise<string | null> {
let keyword = this.repository.getLastKeyword();
if (!keyword) {
keyword = await this.client.getGlobalLastKeyword();
}
return keyword;
}
private async saveKeyword(keyword: string): Promise<void> {
this.repository.setLastKeyword(keyword);
await this.client.setGlobalLastKeyword(keyword);
}
private async showNoLastKeywordError(): Promise<void> {
await this.consoleClient.error('No previous search keywords');
}
}

@ -0,0 +1,16 @@
import FocusPresenter, { FocusPresenterImpl }
from '../presenters/FocusPresenter';
export default class FocusUseCases {
private presenter: FocusPresenter;
constructor({
presenter = new FocusPresenterImpl(),
} = {}) {
this.presenter = presenter;
}
focusFirstInput() {
this.presenter.focusFirstElement();
}
}

@ -0,0 +1,150 @@
import FollowKeyRepository, { FollowKeyRepositoryImpl }
from '../repositories/FollowKeyRepository';
import FollowMasterRepository, { FollowMasterRepositoryImpl }
from '../repositories/FollowMasterRepository';
import FollowSlaveClient, { FollowSlaveClientImpl }
from '../client/FollowSlaveClient';
import HintKeyProducer from './HintKeyProducer';
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
export default class FollowMasterUseCase {
private followKeyRepository: FollowKeyRepository;
private followMasterRepository: FollowMasterRepository;
private settingRepository: SettingRepository;
// TODO Make repository
private producer: HintKeyProducer | null;
constructor({
followKeyRepository = new FollowKeyRepositoryImpl(),
followMasterRepository = new FollowMasterRepositoryImpl(),
settingRepository = new SettingRepositoryImpl(),
} = {}) {
this.followKeyRepository = followKeyRepository;
this.followMasterRepository = followMasterRepository;
this.settingRepository = settingRepository;
this.producer = null;
}
startFollow(newTab: boolean, background: boolean): void {
let hintchars = this.settingRepository.get().properties.hintchars;
this.producer = new HintKeyProducer(hintchars);
this.followKeyRepository.clearKeys();
this.followMasterRepository.setCurrentFollowMode(newTab, background);
let viewWidth = window.top.innerWidth;
let viewHeight = window.top.innerHeight;
new FollowSlaveClientImpl(window.top).requestHintCount(
{ width: viewWidth, height: viewHeight },
{ x: 0, y: 0 },
);
let frameElements = window.document.querySelectorAll('iframe');
for (let i = 0; i < frameElements.length; ++i) {
let ele = frameElements[i] as HTMLFrameElement | HTMLIFrameElement;
let { left: frameX, top: frameY } = ele.getBoundingClientRect();
new FollowSlaveClientImpl(ele.contentWindow!!).requestHintCount(
{ width: viewWidth, height: viewHeight },
{ x: frameX, y: frameY },
);
}
}
// eslint-disable-next-line max-statements
createSlaveHints(count: number, sender: Window): void {
let produced = [];
for (let i = 0; i < count; ++i) {
let tag = this.producer!!.produce();
produced.push(tag);
this.followMasterRepository.addTag(tag);
}
let doc = window.document;
let viewWidth = window.innerWidth || doc.documentElement.clientWidth;
let viewHeight = window.innerHeight || doc.documentElement.clientHeight;
let pos = { x: 0, y: 0 };
if (sender !== window) {
let frameElements = window.document.querySelectorAll('iframe');
let ele = Array.from(frameElements).find(e => e.contentWindow === sender);
if (!ele) {
// elements of the sender is gone
return;
}
let { left: frameX, top: frameY } = ele.getBoundingClientRect();
pos = { x: frameX, y: frameY };
}
new FollowSlaveClientImpl(sender).createHints(
{ width: viewWidth, height: viewHeight },
pos,
produced,
);
}
cancelFollow(): void {
this.followMasterRepository.clearTags();
this.broadcastToSlaves((client) => {
client.clearHints();
});
}
filter(prefix: string): void {
this.broadcastToSlaves((client) => {
client.filterHints(prefix);
});
}
activate(tag: string): void {
this.followMasterRepository.clearTags();
let newTab = this.followMasterRepository.getCurrentNewTabMode();
let background = this.followMasterRepository.getCurrentBackgroundMode();
this.broadcastToSlaves((client) => {
client.activateIfExists(tag, newTab, background);
client.clearHints();
});
}
enqueue(key: string): void {
switch (key) {
case 'Enter':
this.activate(this.getCurrentTag());
return;
case 'Esc':
this.cancelFollow();
return;
case 'Backspace':
case 'Delete':
this.followKeyRepository.popKey();
this.filter(this.getCurrentTag());
return;
}
this.followKeyRepository.pushKey(key);
let tag = this.getCurrentTag();
let matched = this.followMasterRepository.getTagsByPrefix(tag);
if (matched.length === 0) {
this.cancelFollow();
} else if (matched.length === 1) {
this.activate(tag);
} else {
this.filter(tag);
}
}
private broadcastToSlaves(handler: (client: FollowSlaveClient) => void) {
let allFrames = [window.self].concat(Array.from(window.frames as any));
let clients = allFrames.map(frame => new FollowSlaveClientImpl(frame));
for (let client of clients) {
handler(client);
}
}
private getCurrentTag(): string {
return this.followKeyRepository.getKeys().join('');
}
}

@ -0,0 +1,91 @@
import FollowSlaveRepository, { FollowSlaveRepositoryImpl }
from '../repositories/FollowSlaveRepository';
import FollowPresenter, { FollowPresenterImpl }
from '../presenters/FollowPresenter';
import TabsClient, { TabsClientImpl } from '../client/TabsClient';
import { LinkHint, InputHint } from '../presenters/Hint';
import FollowMasterClient, { FollowMasterClientImpl }
from '../client/FollowMasterClient';
import Key from '../domains/Key';
interface Size {
width: number;
height: number;
}
interface Point {
x: number;
y: number;
}
export default class FollowSlaveUseCase {
private presenter: FollowPresenter;
private tabsClient: TabsClient;
private followMasterClient: FollowMasterClient;
private followSlaveRepository: FollowSlaveRepository;
constructor({
presenter = new FollowPresenterImpl(),
tabsClient = new TabsClientImpl(),
followMasterClient = new FollowMasterClientImpl(window.top),
followSlaveRepository = new FollowSlaveRepositoryImpl(),
} = {}) {
this.presenter = presenter;
this.tabsClient = tabsClient;
this.followMasterClient = followMasterClient;
this.followSlaveRepository = followSlaveRepository;
}
countTargets(viewSize: Size, framePosition: Point): void {
let count = this.presenter.getTargetCount(viewSize, framePosition);
this.followMasterClient.responseHintCount(count);
}
createHints(viewSize: Size, framePosition: Point, tags: string[]): void {
this.followSlaveRepository.enableFollowMode();
this.presenter.createHints(viewSize, framePosition, tags);
}
showHints(prefix: string) {
this.presenter.filterHints(prefix);
}
sendKey(key: Key): void {
this.followMasterClient.sendKey(key);
}
isFollowMode(): boolean {
return this.followSlaveRepository.isFollowMode();
}
async activate(tag: string, newTab: boolean, background: boolean) {
let hint = this.presenter.getHint(tag);
if (!hint) {
return;
}
if (hint instanceof LinkHint) {
let url = hint.getLink();
// ignore taget='_blank'
if (!newTab && hint.getLinkTarget() === '_blank') {
hint.click();
return;
}
// eslint-disable-next-line no-script-url
if (!url || url === '#' || url.toLowerCase().startsWith('javascript:')) {
return;
}
await this.tabsClient.openUrl(url, newTab, background);
} else if (hint instanceof InputHint) {
hint.activate();
}
}
clear(): void {
this.followSlaveRepository.disableFollowMode();
this.presenter.clearHints();
}
}

@ -0,0 +1,38 @@
export default class HintKeyProducer {
private charset: string;
private counter: number[];
constructor(charset: string) {
if (charset.length === 0) {
throw new TypeError('charset is empty');
}
this.charset = charset;
this.counter = [];
}
produce(): string {
this.increment();
return this.counter.map(x => this.charset[x]).join('');
}
private increment(): void {
let max = this.charset.length - 1;
if (this.counter.every(x => x === max)) {
this.counter = new Array(this.counter.length + 1).fill(0);
return;
}
this.counter.reverse();
let len = this.charset.length;
let num = this.counter.reduce((x, y, index) => x + y * len ** index) + 1;
for (let i = 0; i < this.counter.length; ++i) {
this.counter[i] = num % len;
num = ~~(num / len);
}
this.counter.reverse();
}
}

@ -0,0 +1,87 @@
import KeymapRepository, { KeymapRepositoryImpl }
from '../repositories/KeymapRepository';
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
import AddonEnabledRepository, { AddonEnabledRepositoryImpl }
from '../repositories/AddonEnabledRepository';
import * as operations from '../../shared/operations';
import { Keymaps } from '../../shared/Settings';
import Key from '../domains/Key';
import KeySequence, * as keySequenceUtils from '../domains/KeySequence';
type KeymapEntityMap = Map<KeySequence, operations.Operation>;
const reservedKeymaps: Keymaps = {
'<Esc>': { type: operations.CANCEL },
'<C-[>': { type: operations.CANCEL },
};
export default class KeymapUseCase {
private repository: KeymapRepository;
private settingRepository: SettingRepository;
private addonEnabledRepository: AddonEnabledRepository;
constructor({
repository = new KeymapRepositoryImpl(),
settingRepository = new SettingRepositoryImpl(),
addonEnabledRepository = new AddonEnabledRepositoryImpl(),
} = {}) {
this.repository = repository;
this.settingRepository = settingRepository;
this.addonEnabledRepository = addonEnabledRepository;
}
nextOp(key: Key): operations.Operation | null {
let sequence = this.repository.enqueueKey(key);
let keymaps = this.keymapEntityMap();
let matched = Array.from(keymaps.keys()).filter(
(mapping: KeySequence) => {
return mapping.startsWith(sequence);
});
if (!this.addonEnabledRepository.get()) {
// available keymaps are only ADDON_ENABLE and ADDON_TOGGLE_ENABLED if
// the addon disabled
matched = matched.filter((keymap) => {
let type = (keymaps.get(keymap) as operations.Operation).type;
return type === operations.ADDON_ENABLE ||
type === operations.ADDON_TOGGLE_ENABLED;
});
}
if (matched.length === 0) {
// No operations to match with inputs
this.repository.clear();
return null;
} else if (matched.length > 1 ||
matched.length === 1 && sequence.length() < matched[0].length()) {
// More than one operations are matched
return null;
}
// Exactly one operation is matched
let operation = keymaps.get(matched[0]) as operations.Operation;
this.repository.clear();
return operation;
}
clear(): void {
this.repository.clear();
}
private keymapEntityMap(): KeymapEntityMap {
let keymaps = {
...this.settingRepository.get().keymaps,
...reservedKeymaps,
};
let entries = Object.entries(keymaps).map((entry) => {
return [
keySequenceUtils.fromMapKeys(entry[0]),
entry[1],
];
}) as [KeySequence, operations.Operation][];
return new Map<KeySequence, operations.Operation>(entries);
}
}

@ -0,0 +1,36 @@
import MarkKeyRepository, { MarkKeyRepositoryImpl }
from '../repositories/MarkKeyRepository';
export default class MarkKeyUseCase {
private repository: MarkKeyRepository;
constructor({
repository = new MarkKeyRepositoryImpl()
} = {}) {
this.repository = repository;
}
isSetMode(): boolean {
return this.repository.isSetMode();
}
isJumpMode(): boolean {
return this.repository.isJumpMode();
}
enableSetMode(): void {
this.repository.enableSetMode();
}
disableSetMode(): void {
this.repository.disabeSetMode();
}
enableJumpMode(): void {
this.repository.enableJumpMode();
}
disableJumpMode(): void {
this.repository.disabeJumpMode();
}
}

@ -0,0 +1,66 @@
import ScrollPresenter, { ScrollPresenterImpl }
from '../presenters/ScrollPresenter';
import MarkClient, { MarkClientImpl } from '../client/MarkClient';
import MarkRepository, { MarkRepositoryImpl }
from '../repositories/MarkRepository';
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
import ConsoleClient, { ConsoleClientImpl } from '../client/ConsoleClient';
export default class MarkUseCase {
private scrollPresenter: ScrollPresenter;
private client: MarkClient;
private repository: MarkRepository;
private settingRepository: SettingRepository;
private consoleClient: ConsoleClient;
constructor({
scrollPresenter = new ScrollPresenterImpl(),
client = new MarkClientImpl(),
repository = new MarkRepositoryImpl(),
settingRepository = new SettingRepositoryImpl(),
consoleClient = new ConsoleClientImpl(),
} = {}) {
this.scrollPresenter = scrollPresenter;
this.client = client;
this.repository = repository;
this.settingRepository = settingRepository;
this.consoleClient = consoleClient;
}
async set(key: string): Promise<void> {
let pos = this.scrollPresenter.getScroll();
if (this.globalKey(key)) {
this.client.setGloablMark(key, pos);
await this.consoleClient.info(`Set global mark to '${key}'`);
} else {
this.repository.set(key, pos);
await this.consoleClient.info(`Set local mark to '${key}'`);
}
}
async jump(key: string): Promise<void> {
if (this.globalKey(key)) {
await this.client.jumpGlobalMark(key);
} else {
let pos = this.repository.get(key);
if (!pos) {
throw new Error('Mark is not set');
}
this.scroll(pos.x, pos.y);
}
}
scroll(x: number, y: number): void {
let smooth = this.settingRepository.get().properties.smoothscroll;
this.scrollPresenter.scrollTo(x, y, smooth);
}
private globalKey(key: string) {
return (/^[A-Z0-9]$/).test(key);
}
}

@ -0,0 +1,36 @@
import NavigationPresenter, { NavigationPresenterImpl }
from '../presenters/NavigationPresenter';
export default class NavigateUseCase {
private navigationPresenter: NavigationPresenter;
constructor({
navigationPresenter = new NavigationPresenterImpl(),
} = {}) {
this.navigationPresenter = navigationPresenter;
}
openHistoryPrev(): void {
this.navigationPresenter.openHistoryPrev();
}
openHistoryNext(): void {
this.navigationPresenter.openHistoryNext();
}
openLinkPrev(): void {
this.navigationPresenter.openLinkPrev();
}
openLinkNext(): void {
this.navigationPresenter.openLinkNext();
}
openParent(): void {
this.navigationPresenter.openParent();
}
openRoot(): void {
this.navigationPresenter.openRoot();
}
}

@ -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;
}
}

@ -0,0 +1,24 @@
import SettingRepository, { SettingRepositoryImpl }
from '../repositories/SettingRepository';
import SettingClient, { SettingClientImpl } from '../client/SettingClient';
import Settings from '../../shared/Settings';
export default class SettingUseCase {
private repository: SettingRepository;
private client: SettingClient;
constructor({
repository = new SettingRepositoryImpl(),
client = new SettingClientImpl(),
} = {}) {
this.repository = repository;
this.client = client;
}
async reload(): Promise<Settings> {
let settings = await this.client.load();
this.repository.set(settings);
return settings;
}
}

@ -42,162 +42,164 @@ export const SETTINGS_QUERY = 'settings.query';
export const CONSOLE_FRAME_MESSAGE = 'console.frame.message';
interface BackgroundOperationMessage {
export interface BackgroundOperationMessage {
type: typeof BACKGROUND_OPERATION;
operation: operations.Operation;
}
interface ConsoleUnfocusMessage {
export interface ConsoleUnfocusMessage {
type: typeof CONSOLE_UNFOCUS;
}
interface ConsoleEnterCommandMessage {
export interface ConsoleEnterCommandMessage {
type: typeof CONSOLE_ENTER_COMMAND;
text: string;
}
interface ConsoleEnterFindMessage {
export interface ConsoleEnterFindMessage {
type: typeof CONSOLE_ENTER_FIND;
text: string;
text?: string;
}
interface ConsoleQueryCompletionsMessage {
export interface ConsoleQueryCompletionsMessage {
type: typeof CONSOLE_QUERY_COMPLETIONS;
text: string;
}
interface ConsoleShowCommandMessage {
export interface ConsoleShowCommandMessage {
type: typeof CONSOLE_SHOW_COMMAND;
command: string;
}
interface ConsoleShowErrorMessage {
export interface ConsoleShowErrorMessage {
type: typeof CONSOLE_SHOW_ERROR;
text: string;
}
interface ConsoleShowInfoMessage {
export interface ConsoleShowInfoMessage {
type: typeof CONSOLE_SHOW_INFO;
text: string;
}
interface ConsoleShowFindMessage {
export interface ConsoleShowFindMessage {
type: typeof CONSOLE_SHOW_FIND;
}
interface ConsoleHideMessage {
export interface ConsoleHideMessage {
type: typeof CONSOLE_HIDE;
}
interface FollowStartMessage {
export interface FollowStartMessage {
type: typeof FOLLOW_START;
newTab: boolean;
background: boolean;
}
interface FollowRequestCountTargetsMessage {
export interface FollowRequestCountTargetsMessage {
type: typeof FOLLOW_REQUEST_COUNT_TARGETS;
viewSize: { width: number, height: number };
framePosition: { x: number, y: number };
}
interface FollowResponseCountTargetsMessage {
export interface FollowResponseCountTargetsMessage {
type: typeof FOLLOW_RESPONSE_COUNT_TARGETS;
count: number;
}
interface FollowCreateHintsMessage {
export interface FollowCreateHintsMessage {
type: typeof FOLLOW_CREATE_HINTS;
keysArray: string[];
newTab: boolean;
background: boolean;
tags: string[];
viewSize: { width: number, height: number };
framePosition: { x: number, y: number };
}
interface FollowShowHintsMessage {
export interface FollowShowHintsMessage {
type: typeof FOLLOW_SHOW_HINTS;
keys: string;
prefix: string;
}
interface FollowRemoveHintsMessage {
export interface FollowRemoveHintsMessage {
type: typeof FOLLOW_REMOVE_HINTS;
}
interface FollowActivateMessage {
export interface FollowActivateMessage {
type: typeof FOLLOW_ACTIVATE;
keys: string;
tag: string;
newTab: boolean;
background: boolean;
}
interface FollowKeyPressMessage {
export interface FollowKeyPressMessage {
type: typeof FOLLOW_KEY_PRESS;
key: string;
ctrlKey: boolean;
}
interface MarkSetGlobalMessage {
export interface MarkSetGlobalMessage {
type: typeof MARK_SET_GLOBAL;
key: string;
x: number;
y: number;
}
interface MarkJumpGlobalMessage {
export interface MarkJumpGlobalMessage {
type: typeof MARK_JUMP_GLOBAL;
key: string;
}
interface TabScrollToMessage {
export interface TabScrollToMessage {
type: typeof TAB_SCROLL_TO;
x: number;
y: number;
}
interface FindNextMessage {
export interface FindNextMessage {
type: typeof FIND_NEXT;
}
interface FindPrevMessage {
export interface FindPrevMessage {
type: typeof FIND_PREV;
}
interface FindGetKeywordMessage {
export interface FindGetKeywordMessage {
type: typeof FIND_GET_KEYWORD;
}
interface FindSetKeywordMessage {
export interface FindSetKeywordMessage {
type: typeof FIND_SET_KEYWORD;
keyword: string;
found: boolean;
}
interface AddonEnabledQueryMessage {
export interface AddonEnabledQueryMessage {
type: typeof ADDON_ENABLED_QUERY;
}
interface AddonEnabledResponseMessage {
export interface AddonEnabledResponseMessage {
type: typeof ADDON_ENABLED_RESPONSE;
enabled: boolean;
}
interface AddonToggleEnabledMessage {
export interface AddonToggleEnabledMessage {
type: typeof ADDON_TOGGLE_ENABLED;
}
interface OpenUrlMessage {
export interface OpenUrlMessage {
type: typeof OPEN_URL;
url: string;
newTab: boolean;
background: boolean;
}
interface SettingsChangedMessage {
export interface SettingsChangedMessage {
type: typeof SETTINGS_CHANGED;
}
interface SettingsQueryMessage {
export interface SettingsQueryMessage {
type: typeof SETTINGS_QUERY;
}
interface ConsoleFrameMessageMessage {
export interface ConsoleFrameMessageMessage {
type: typeof CONSOLE_FRAME_MESSAGE;
message: any;
}

@ -1,3 +1,5 @@
import { Search } from './Settings';
const trimStart = (str: string): string => {
// NOTE String.trimStart is available on Firefox 61
return str.replace(/^\s+/, '');
@ -5,7 +7,7 @@ const trimStart = (str: string): string => {
const SUPPORTED_PROTOCOLS = ['http:', 'https:', 'ftp:', 'mailto:', 'about:'];
const searchUrl = (keywords: string, searchSettings: any): string => {
const searchUrl = (keywords: string, search: Search): string => {
try {
let u = new URL(keywords);
if (SUPPORTED_PROTOCOLS.includes(u.protocol.toLowerCase())) {
@ -17,12 +19,12 @@ const searchUrl = (keywords: string, searchSettings: any): string => {
if (keywords.includes('.') && !keywords.includes(' ')) {
return 'http://' + keywords;
}
let template = searchSettings.engines[searchSettings.default];
let template = search.engines[search.default];
let query = keywords;
let first = trimStart(keywords).split(' ')[0];
if (Object.keys(searchSettings.engines).includes(first)) {
template = searchSettings.engines[first];
if (Object.keys(search.engines).includes(first)) {
template = search.engines[first];
query = trimStart(trimStart(keywords).slice(first.length));
}
return template.replace('{}', encodeURIComponent(query));

@ -0,0 +1,129 @@
import InputDriver from '../../src/content/InputDriver';
import { expect } from 'chai';
import Key from '../../src/content/domains/Key';
describe('InputDriver', () => {
let target: HTMLElement;
let driver: InputDriver;
beforeEach(() => {
target = document.createElement('div');
document.body.appendChild(target);
driver = new InputDriver(target);
});
afterEach(() => {
target.remove();
target = null;
driver = null;
});
it('register callbacks', (done) => {
driver.onKey((key: Key): boolean => {
expect(key.key).to.equal('a');
expect(key.ctrlKey).to.be.true;
expect(key.shiftKey).to.be.false;
expect(key.altKey).to.be.false;
expect(key.metaKey).to.be.false;
done();
return true;
});
target.dispatchEvent(new KeyboardEvent('keydown', {
key: 'a',
ctrlKey: true,
shiftKey: false,
altKey: false,
metaKey: false,
}));
});
it('invoke callback once', () => {
let a = 0, b = 0;
driver.onKey((key: Key): boolean => {
if (key.key == 'a') {
++a;
} else {
key.key == 'b'
++b;
}
return true;
});
let events = [
new KeyboardEvent('keydown', { key: 'a' }),
new KeyboardEvent('keydown', { key: 'b' }),
new KeyboardEvent('keypress', { key: 'a' }),
new KeyboardEvent('keyup', { key: 'a' }),
new KeyboardEvent('keypress', { key: 'b' }),
new KeyboardEvent('keyup', { key: 'b' }),
];
for (let e of events) {
target.dispatchEvent(e);
}
expect(a).to.equal(1);
expect(b).to.equal(1);
})
it('propagates and stop handler chain', () => {
let a = 0, b = 0, c = 0;
driver.onKey((key: Key): boolean => {
a++;
return false;
});
driver.onKey((key: Key): boolean => {
b++;
return true;
});
driver.onKey((key: Key): boolean => {
c++;
return true;
});
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'b' }));
expect(a).to.equal(1);
expect(b).to.equal(1);
expect(c).to.equal(0);
})
it('does not invoke only meta keys', () => {
driver.onKey((key: Key): boolean=> {
expect.fail();
return false;
});
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Shift' }));
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Control' }));
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' }));
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'OS' }));
})
it('ignores events from input elements', () => {
['input', 'textarea', 'select'].forEach((name) => {
let input = window.document.createElement(name);
let driver = new InputDriver(input);
driver.onKey((key: Key): boolean => {
expect.fail();
return false;
});
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
});
});
it('ignores events from contenteditable elements', () => {
let div = window.document.createElement('div');
let driver = new InputDriver(div);
driver.onKey((key: Key): boolean => {
expect.fail();
return false;
});
div.setAttribute('contenteditable', '');
div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
div.setAttribute('contenteditable', 'true');
div.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
});
});

@ -1,34 +0,0 @@
import * as actions from 'content/actions';
import * as followControllerActions from 'content/actions/follow-controller';
describe('follow-controller actions', () => {
describe('enable', () => {
it('creates FOLLOW_CONTROLLER_ENABLE action', () => {
let action = followControllerActions.enable(true);
expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_ENABLE);
expect(action.newTab).to.equal(true);
});
});
describe('disable', () => {
it('creates FOLLOW_CONTROLLER_DISABLE action', () => {
let action = followControllerActions.disable(true);
expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_DISABLE);
});
});
describe('keyPress', () => {
it('creates FOLLOW_CONTROLLER_KEY_PRESS action', () => {
let action = followControllerActions.keyPress(100);
expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_KEY_PRESS);
expect(action.key).to.equal(100);
});
});
describe('backspace', () => {
it('creates FOLLOW_CONTROLLER_BACKSPACE action', () => {
let action = followControllerActions.backspace(100);
expect(action.type).to.equal(actions.FOLLOW_CONTROLLER_BACKSPACE);
});
});
});

@ -1,19 +0,0 @@
import * as actions from 'content/actions';
import * as inputActions from 'content/actions/input';
describe("input actions", () => {
describe("keyPress", () => {
it('create INPUT_KEY_PRESS action', () => {
let action = inputActions.keyPress('a');
expect(action.type).to.equal(actions.INPUT_KEY_PRESS);
expect(action.key).to.equal('a');
});
});
describe("clearKeys", () => {
it('create INPUT_CLEAR_KEYSaction', () => {
let action = inputActions.clearKeys();
expect(action.type).to.equal(actions.INPUT_CLEAR_KEYS);
});
});
});

@ -1,35 +0,0 @@
import * as actions from 'content/actions';
import * as markActions from 'content/actions/mark';
describe('mark actions', () => {
describe('startSet', () => {
it('create MARK_START_SET action', () => {
let action = markActions.startSet();
expect(action.type).to.equal(actions.MARK_START_SET);
});
});
describe('startJump', () => {
it('create MARK_START_JUMP action', () => {
let action = markActions.startJump();
expect(action.type).to.equal(actions.MARK_START_JUMP);
});
});
describe('cancel', () => {
it('create MARK_CANCEL action', () => {
let action = markActions.cancel();
expect(action.type).to.equal(actions.MARK_CANCEL);
});
});
describe('setLocal', () => {
it('create setLocal action', () => {
let action = markActions.setLocal('a', 20, 30);
expect(action.type).to.equal(actions.MARK_SET_LOCAL);
expect(action.key).to.equal('a');
expect(action.x).to.equal(20);
expect(action.y).to.equal(30);
});
});
});

@ -1,43 +0,0 @@
import * as actions from 'content/actions';
import * as settingActions from 'content/actions/setting';
describe("setting actions", () => {
describe("set", () => {
it('create SETTING_SET action', () => {
let action = settingActions.set({
keymaps: {
'dd': 'remove current tab',
'z<C-A>': 'increment',
},
search: {
default: "google",
engines: {
google: 'https://google.com/search?q={}',
}
},
properties: {
hintchars: 'abcd1234',
},
blacklist: [],
});
expect(action.type).to.equal(actions.SETTING_SET);
expect(action.settings.properties.hintchars).to.equal('abcd1234');
});
it('overrides cancel keys', () => {
let action = settingActions.set({
keymaps: {
"k": { "type": "scroll.vertically", "count": -1 },
"j": { "type": "scroll.vertically", "count": 1 },
}
});
let keymaps = action.settings.keymaps;
expect(action.settings.keymaps).to.deep.equals({
"k": { type: "scroll.vertically", count: -1 },
"j": { type: "scroll.vertically", count: 1 },
'<Esc>': { type: 'cancel' },
'<C-[>': { type: 'cancel' },
});
});
});
});

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html>
<body>
<a id='visible_a' href='#' >link</a>
<a href='#' style='display:none'>invisible 1</a>
<a href='#' style='visibility:hidden'>invisible 2</a>
<i>not link<i>
<div id='editable_div_1' contenteditable>link</div>
<div id='editable_div_2' contenteditable='true'>link</div>
<div id='x' contenteditable='false'>link</div>
<details>
<summary id='summary_1'>summary link</summary>
Some details
<a href='#'>not visible</a>
</details>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More