Define client and presenter for follow

jh-changes
Shin'ya Ueoka 6 years ago
parent 17dc2bb5ec
commit a88324acd9
  1. 6
      src/content/MessageListener.ts
  2. 8
      src/content/actions/operation.ts
  3. 47
      src/content/client/FollowMasterClient.ts
  4. 76
      src/content/client/FollowSlaveClient.ts
  5. 163
      src/content/components/common/follow.ts
  6. 2
      src/content/components/common/index.ts
  7. 93
      src/content/components/top-content/follow-controller.ts
  8. 134
      src/content/presenters/FollowPresenter.ts
  9. 8
      src/shared/messages.ts

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

@ -9,11 +9,13 @@ import AddonEnabledUseCase from '../usecases/AddonEnabledUseCase';
import ClipboardUseCase from '../usecases/ClipboardUseCase'; import ClipboardUseCase from '../usecases/ClipboardUseCase';
import { SettingRepositoryImpl } from '../repositories/SettingRepository'; import { SettingRepositoryImpl } from '../repositories/SettingRepository';
import { ScrollPresenterImpl } from '../presenters/ScrollPresenter'; import { ScrollPresenterImpl } from '../presenters/ScrollPresenter';
import { FollowMasterClientImpl } from '../client/FollowMasterClient';
let addonEnabledUseCase = new AddonEnabledUseCase(); let addonEnabledUseCase = new AddonEnabledUseCase();
let clipbaordUseCase = new ClipboardUseCase(); let clipbaordUseCase = new ClipboardUseCase();
let settingRepository = new SettingRepositoryImpl(); let settingRepository = new SettingRepositoryImpl();
let scrollPresenter = new ScrollPresenterImpl(); let scrollPresenter = new ScrollPresenterImpl();
let followMasterClient = new FollowMasterClientImpl(window.top);
// eslint-disable-next-line complexity, max-lines-per-function // eslint-disable-next-line complexity, max-lines-per-function
const exec = async( const exec = async(
@ -63,11 +65,7 @@ const exec = async(
scrollPresenter.scrollToEnd(smoothscroll); scrollPresenter.scrollToEnd(smoothscroll);
break; break;
case operations.FOLLOW_START: case operations.FOLLOW_START:
window.top.postMessage(JSON.stringify({ followMasterClient.startFollow(operation.newTab, operation.background);
type: messages.FOLLOW_START,
newTab: operation.newTab,
background: operation.background,
}), '*');
break; break;
case operations.MARK_SET_PREFIX: case operations.MARK_SET_PREFIX:
return markActions.startSet(); return markActions.startSet();

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

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

@ -1,17 +1,18 @@
import MessageListener from '../../MessageListener'; import MessageListener from '../../MessageListener';
import Hint, { LinkHint, InputHint } from '../../presenters/Hint'; import { LinkHint, InputHint } from '../../presenters/Hint';
import * as dom from '../../../shared/utils/dom';
import * as messages from '../../../shared/messages'; import * as messages from '../../../shared/messages';
import * as keyUtils from '../../../shared/utils/keys'; import { Key } from '../../../shared/utils/keys';
import TabsClient, { TabsClientImpl } from '../../client/TabsClient'; import TabsClient, { TabsClientImpl } from '../../client/TabsClient';
import FollowMasterClient, { FollowMasterClientImpl }
from '../../client/FollowMasterClient';
import FollowPresenter, { FollowPresenterImpl }
from '../../presenters/FollowPresenter';
let tabsClient: TabsClient = new TabsClientImpl(); let tabsClient: TabsClient = new TabsClientImpl();
let followMasterClient: FollowMasterClient =
const TARGET_SELECTOR = [ new FollowMasterClientImpl(window.top);
'a', 'button', 'input', 'textarea', 'area', let followPresenter: FollowPresenter =
'[contenteditable=true]', '[contenteditable=""]', '[tabindex]', new FollowPresenterImpl();
'[role="button"]', 'summary'
].join(',');
interface Size { interface Size {
width: number; width: number;
@ -23,118 +24,46 @@ interface Point {
y: 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 { export default class Follow {
private win: Window; private enabled: boolean;
private hints: {[key: string]: Hint }; constructor() {
this.enabled = false;
private targets: HTMLElement[] = [];
constructor(win: Window) {
this.win = win;
this.hints = {};
this.targets = [];
new MessageListener().onWebMessage(this.onMessage.bind(this)); new MessageListener().onWebMessage(this.onMessage.bind(this));
} }
key(key: keyUtils.Key): boolean { key(key: Key): boolean {
if (Object.keys(this.hints).length === 0) { if (!this.enabled) {
return false; return false;
} }
this.win.parent.postMessage(JSON.stringify({ followMasterClient.sendKey(key);
type: messages.FOLLOW_KEY_PRESS,
key: key.key,
ctrlKey: key.ctrlKey,
}), '*');
return true; return true;
} }
countHints(sender: any, viewSize: Size, framePosition: Point) { countHints(viewSize: Size, framePosition: Point) {
this.targets = Follow.getTargetElements(this.win, viewSize, framePosition); let count = followPresenter.getTargetCount(viewSize, framePosition);
sender.postMessage(JSON.stringify({ followMasterClient.responseHintCount(count);
type: messages.FOLLOW_RESPONSE_COUNT_TARGETS,
count: this.targets.length,
}), '*');
}
createHints(keysArray: string[]) {
if (keysArray.length !== this.targets.length) {
throw new Error('illegal hint count');
} }
this.hints = {}; createHints(viewSize: Size, framePosition: Point, tags: string[]) {
for (let i = 0; i < keysArray.length; ++i) { this.enabled = true;
let keys = keysArray[i]; followPresenter.createHints(viewSize, framePosition, tags);
let target = this.targets[i];
if (target instanceof HTMLAnchorElement ||
target instanceof HTMLAreaElement) {
this.hints[keys] = new LinkHint(target, keys);
} else {
this.hints[keys] = new InputHint(target, keys);
}
}
} }
showHints(keys: string) { showHints(prefix: string) {
Object.keys(this.hints).filter(key => key.startsWith(keys)) followPresenter.filterHints(prefix);
.forEach(key => this.hints[key].show());
Object.keys(this.hints).filter(key => !key.startsWith(keys))
.forEach(key => this.hints[key].hide());
} }
removeHints() { removeHints() {
Object.keys(this.hints).forEach((key) => { followPresenter.clearHints();
this.hints[key].remove(); this.enabled = false;
});
this.hints = {};
this.targets = [];
} }
async activateHints(keys: string, newTab: boolean, background: boolean): Promise<void> { async activateHints(
let hint = this.hints[keys]; tag: string, newTab: boolean, background: boolean,
): Promise<void> {
let hint = followPresenter.getHint(tag);
if (!hint) { if (!hint) {
return; return;
} }
@ -156,38 +85,20 @@ export default class Follow {
} }
} }
onMessage(message: messages.Message, sender: any) { onMessage(message: messages.Message, _sender: Window) {
switch (message.type) { switch (message.type) {
case messages.FOLLOW_REQUEST_COUNT_TARGETS: case messages.FOLLOW_REQUEST_COUNT_TARGETS:
return this.countHints(sender, message.viewSize, message.framePosition); return this.countHints(message.viewSize, message.framePosition);
case messages.FOLLOW_CREATE_HINTS: case messages.FOLLOW_CREATE_HINTS:
return this.createHints(message.keysArray); return this.createHints(
message.viewSize, message.framePosition, message.tags);
case messages.FOLLOW_SHOW_HINTS: case messages.FOLLOW_SHOW_HINTS:
return this.showHints(message.keys); return this.showHints(message.prefix);
case messages.FOLLOW_ACTIVATE: case messages.FOLLOW_ACTIVATE:
return this.activateHints(message.keys, message.newTab, message.background); return this.activateHints(
message.tag, message.newTab, message.background);
case messages.FOLLOW_REMOVE_HINTS: case messages.FOLLOW_REMOVE_HINTS:
return this.removeHints(); 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;
}
} }

@ -16,7 +16,7 @@ let settingUseCase = new SettingUseCase();
export default class Common { export default class Common {
constructor(win: Window, store: any) { constructor(win: Window, store: any) {
const input = new InputComponent(win.document.body); const input = new InputComponent(win.document.body);
const follow = new FollowComponent(win); const follow = new FollowComponent();
const mark = new MarkComponent(store); const mark = new MarkComponent(store);
const keymapper = new KeymapperComponent(store); const keymapper = new KeymapperComponent(store);

@ -1,18 +1,14 @@
import * as followControllerActions from '../../actions/follow-controller'; import * as followControllerActions from '../../actions/follow-controller';
import * as messages from '../../../shared/messages'; import * as messages from '../../../shared/messages';
import MessageListener, { WebMessageSender } from '../../MessageListener'; import MessageListener from '../../MessageListener';
import HintKeyProducer from '../../hint-key-producer'; import HintKeyProducer from '../../hint-key-producer';
import { SettingRepositoryImpl } from '../../repositories/SettingRepository'; import { SettingRepositoryImpl } from '../../repositories/SettingRepository';
import FollowSlaveClient, { FollowSlaveClientImpl }
from '../../client/FollowSlaveClient';
let settingRepository = new SettingRepositoryImpl(); let settingRepository = new SettingRepositoryImpl();
const broadcastMessage = (win: Window, message: messages.Message): void => {
let json = JSON.stringify(message);
let frames = [win.self].concat(Array.from(win.frames as any));
frames.forEach(frame => frame.postMessage(json, '*'));
};
export default class FollowController { export default class FollowController {
private win: Window; private win: Window;
@ -43,7 +39,7 @@ export default class FollowController {
}); });
} }
onMessage(message: messages.Message, sender: WebMessageSender) { onMessage(message: messages.Message, sender: Window) {
switch (message.type) { switch (message.type) {
case messages.FOLLOW_START: case messages.FOLLOW_START:
return this.store.dispatch( return this.store.dispatch(
@ -77,18 +73,17 @@ export default class FollowController {
this.store.dispatch(followControllerActions.disable()); this.store.dispatch(followControllerActions.disable());
} }
broadcastMessage(this.win, { this.broadcastMessage((c: FollowSlaveClient) => {
type: messages.FOLLOW_SHOW_HINTS, c.filterHints(this.state.keys!!);
keys: this.state.keys as string,
}); });
} }
activate(): void { activate(): void {
broadcastMessage(this.win, { this.broadcastMessage((c: FollowSlaveClient) => {
type: messages.FOLLOW_ACTIVATE, c.activateIfExists(
keys: this.state.keys as string, this.state.keys!!,
newTab: this.state.newTab!!, this.state.newTab!!,
background: this.state.background!!, this.state.background!!);
}); });
} }
@ -123,50 +118,64 @@ export default class FollowController {
let doc = this.win.document; let doc = this.win.document;
let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth; let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth;
let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight; let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight;
let frameElements = this.win.document.querySelectorAll('frame,iframe'); let frameElements = this.win.document.querySelectorAll('iframe');
this.win.postMessage(JSON.stringify({ new FollowSlaveClientImpl(this.win).requestHintCount(
type: messages.FOLLOW_REQUEST_COUNT_TARGETS, { width: viewWidth, height: viewHeight },
viewSize: { width: viewWidth, height: viewHeight }, { x: 0, y: 0 });
framePosition: { x: 0, y: 0 },
}), '*'); for (let ele of Array.from(frameElements)) {
frameElements.forEach((ele) => {
let { left: frameX, top: frameY } = ele.getBoundingClientRect(); let { left: frameX, top: frameY } = ele.getBoundingClientRect();
let message = JSON.stringify({ new FollowSlaveClientImpl(ele.contentWindow!!).requestHintCount(
type: messages.FOLLOW_REQUEST_COUNT_TARGETS, { width: viewWidth, height: viewHeight },
viewSize: { width: viewWidth, height: viewHeight }, { x: frameX, y: frameY },
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) { create(count: number, sender: Window) {
let produced = []; let produced = [];
for (let i = 0; i < count; ++i) { for (let i = 0; i < count; ++i) {
produced.push((this.producer as HintKeyProducer).produce()); produced.push((this.producer as HintKeyProducer).produce());
} }
this.keys = this.keys.concat(produced); this.keys = this.keys.concat(produced);
(sender as Window).postMessage(JSON.stringify({ let doc = this.win.document;
type: messages.FOLLOW_CREATE_HINTS, let viewWidth = this.win.innerWidth || doc.documentElement.clientWidth;
keysArray: produced, let viewHeight = this.win.innerHeight || doc.documentElement.clientHeight;
newTab: this.state.newTab, let pos = { x: 0, y: 0 };
background: this.state.background, if (sender !== window) {
}), '*'); let frameElements = this.win.document.querySelectorAll('iframe');
let ele = Array.from(frameElements).find(e => e.contentWindow === sender);
if (!ele) {
// elements of the sender is gone
return;
}
let { left: frameX, top: frameY } = ele.getBoundingClientRect();
pos = { x: frameX, y: frameY };
}
new FollowSlaveClientImpl(sender).createHints(
{ width: viewWidth, height: viewHeight },
pos,
produced,
);
} }
remove() { remove() {
this.keys = []; this.keys = [];
broadcastMessage(this.win, { this.broadcastMessage((c: FollowSlaveClient) => {
type: messages.FOLLOW_REMOVE_HINTS, c.clearHints();
}); });
} }
private hintchars() { private hintchars() {
return settingRepository.get().properties.hintchars; return settingRepository.get().properties.hintchars;
} }
private broadcastMessage(f: (clinet: FollowSlaveClient) => void) {
let windows = [window.self].concat(Array.from(window.frames as any));
windows
.map(w => new FollowSlaveClientImpl(w))
.forEach(c => f(c));
}
} }

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

@ -108,12 +108,14 @@ export interface FollowResponseCountTargetsMessage {
export interface FollowCreateHintsMessage { export interface FollowCreateHintsMessage {
type: typeof FOLLOW_CREATE_HINTS; type: typeof FOLLOW_CREATE_HINTS;
keysArray: string[]; tags: string[];
viewSize: { width: number, height: number };
framePosition: { x: number, y: number };
} }
export interface FollowShowHintsMessage { export interface FollowShowHintsMessage {
type: typeof FOLLOW_SHOW_HINTS; type: typeof FOLLOW_SHOW_HINTS;
keys: string; prefix: string;
} }
export interface FollowRemoveHintsMessage { export interface FollowRemoveHintsMessage {
@ -122,7 +124,7 @@ export interface FollowRemoveHintsMessage {
export interface FollowActivateMessage { export interface FollowActivateMessage {
type: typeof FOLLOW_ACTIVATE; type: typeof FOLLOW_ACTIVATE;
keys: string; tag: string;
newTab: boolean; newTab: boolean;
background: boolean; background: boolean;
} }