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"]', "[onclick]", "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 => { const { top, left, bottom, right } = doms.viewportRect(element); const doc = win.document; const frameWidth = doc.documentElement.clientWidth; const 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 (const attr of ["aria-hidden", "aria-disabled"]) { const value = element.getAttribute(attr); if (value !== null) { const 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; } export class FollowPresenterImpl implements FollowPresenter { private hints: Hint[]; constructor() { this.hints = []; } getTargetCount(viewSize: Size, framePosition: Point): number { const targets = this.getTargets(viewSize, framePosition); return targets.length; } createHints(viewSize: Size, framePosition: Point, tags: string[]): void { const t0 = performance.now(); const targets = this.getTargets(viewSize, framePosition); const t1 = performance.now(); console.log("Took: " + (t1 - t0)); const min = Math.min(targets.length, tags.length); for (let i = 0; i < min; ++i) { const 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 { const shown = this.hints.filter((h) => h.getTag().startsWith(prefix)); const 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[] { const all = window.document.querySelectorAll(TARGET_SELECTOR); const filtered = Array.prototype.filter.call( all, (element: HTMLElement) => { const style = element.style; // 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; } }