parent
17dc2bb5ec
commit
a88324acd9
9 changed files with 359 additions and 180 deletions
@ -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), '*'); |
||||
} |
||||
} |
@ -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; |
||||
} |
||||
} |
Reference in new issue