diff options
Diffstat (limited to 'src/content/presenters')
-rw-r--r-- | src/content/presenters/ConsoleFramePresenter.ts | 25 | ||||
-rw-r--r-- | src/content/presenters/FindPresenter.ts | 52 | ||||
-rw-r--r-- | src/content/presenters/FocusPresenter.ts | 25 | ||||
-rw-r--r-- | src/content/presenters/FollowPresenter.ts | 134 | ||||
-rw-r--r-- | src/content/presenters/Hint.ts | 127 | ||||
-rw-r--r-- | src/content/presenters/NavigationPresenter.ts | 98 | ||||
-rw-r--r-- | src/content/presenters/ScrollPresenter.ts | 179 |
7 files changed, 640 insertions, 0 deletions
diff --git a/src/content/presenters/ConsoleFramePresenter.ts b/src/content/presenters/ConsoleFramePresenter.ts new file mode 100644 index 0000000..3c7477b --- /dev/null +++ b/src/content/presenters/ConsoleFramePresenter.ts @@ -0,0 +1,25 @@ +export default interface ConsoleFramePresenter { + initialize(): void; + + blur(): void; + + // eslint-disable-next-line semi +} + +export class ConsoleFramePresenterImpl implements ConsoleFramePresenter { + initialize(): void { + let iframe = document.createElement('iframe'); + iframe.src = browser.runtime.getURL('build/console.html'); + iframe.id = 'vimvixen-console-frame'; + iframe.className = 'vimvixen-console-frame'; + document.body.append(iframe); + } + + blur(): void { + let ele = document.getElementById('vimvixen-console-frame'); + if (!ele) { + throw new Error('console frame not created'); + } + ele.blur(); + } +} diff --git a/src/content/presenters/FindPresenter.ts b/src/content/presenters/FindPresenter.ts new file mode 100644 index 0000000..d9bc835 --- /dev/null +++ b/src/content/presenters/FindPresenter.ts @@ -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(); + } + } +} diff --git a/src/content/presenters/FocusPresenter.ts b/src/content/presenters/FocusPresenter.ts new file mode 100644 index 0000000..4cef5bf --- /dev/null +++ b/src/content/presenters/FocusPresenter.ts @@ -0,0 +1,25 @@ +import * as doms from '../../shared/utils/dom'; + +export default interface FocusPresenter { + focusFirstElement(): boolean; + + // eslint-disable-next-line semi +} + +export class FocusPresenterImpl implements FocusPresenter { + focusFirstElement(): boolean { + let inputTypes = ['email', 'number', 'search', 'tel', 'text', 'url']; + let inputSelector = inputTypes.map(type => `input[type=${type}]`).join(','); + let targets = window.document.querySelectorAll(inputSelector + ',textarea'); + let target = Array.from(targets).find(doms.isVisible); + if (target instanceof HTMLInputElement) { + target.focus(); + return true; + } else if (target instanceof HTMLTextAreaElement) { + target.focus(); + return true; + } + return false; + } +} + diff --git a/src/content/presenters/FollowPresenter.ts b/src/content/presenters/FollowPresenter.ts new file mode 100644 index 0000000..f0d115c --- /dev/null +++ b/src/content/presenters/FollowPresenter.ts @@ -0,0 +1,134 @@ +import Hint, { InputHint, LinkHint } from './Hint'; +import * as doms from '../../shared/utils/dom'; + +const TARGET_SELECTOR = [ + 'a', 'button', 'input', 'textarea', 'area', + '[contenteditable=true]', '[contenteditable=""]', '[tabindex]', + '[role="button"]', 'summary' +].join(','); + +interface Size { + width: number; + height: number; +} + +interface Point { + x: number; + y: number; +} + +const inViewport = ( + win: Window, + element: Element, + viewSize: Size, + framePosition: Point, +): boolean => { + let { + top, left, bottom, right + } = doms.viewportRect(element); + let doc = win.document; + let frameWidth = doc.documentElement.clientWidth; + let frameHeight = doc.documentElement.clientHeight; + + if (right < 0 || bottom < 0 || top > frameHeight || left > frameWidth) { + // out of frame + return false; + } + if (right + framePosition.x < 0 || bottom + framePosition.y < 0 || + left + framePosition.x > viewSize.width || + top + framePosition.y > viewSize.height) { + // out of viewport + return false; + } + return true; +}; + +const isAriaHiddenOrAriaDisabled = (win: Window, element: Element): boolean => { + if (!element || win.document.documentElement === element) { + return false; + } + for (let attr of ['aria-hidden', 'aria-disabled']) { + let value = element.getAttribute(attr); + if (value !== null) { + let hidden = value.toLowerCase(); + if (hidden === '' || hidden === 'true') { + return true; + } + } + } + return isAriaHiddenOrAriaDisabled(win, element.parentElement as Element); +}; + +export default interface FollowPresenter { + getTargetCount(viewSize: Size, framePosition: Point): number; + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void; + + filterHints(prefix: string): void; + + clearHints(): void; + + getHint(tag: string): Hint | undefined; + + // eslint-disable-next-line semi +} + +export class FollowPresenterImpl implements FollowPresenter { + private hints: Hint[] + + constructor() { + this.hints = []; + } + + getTargetCount(viewSize: Size, framePosition: Point): number { + let targets = this.getTargets(viewSize, framePosition); + return targets.length; + } + + createHints(viewSize: Size, framePosition: Point, tags: string[]): void { + let targets = this.getTargets(viewSize, framePosition); + let min = Math.min(targets.length, tags.length); + for (let i = 0; i < min; ++i) { + let target = targets[i]; + if (target instanceof HTMLAnchorElement || + target instanceof HTMLAreaElement) { + this.hints.push(new LinkHint(target, tags[i])); + } else { + this.hints.push(new InputHint(target, tags[i])); + } + } + } + + filterHints(prefix: string): void { + let shown = this.hints.filter(h => h.getTag().startsWith(prefix)); + let hidden = this.hints.filter(h => !h.getTag().startsWith(prefix)); + + shown.forEach(h => h.show()); + hidden.forEach(h => h.hide()); + } + + clearHints(): void { + this.hints.forEach(h => h.remove()); + this.hints = []; + } + + getHint(tag: string): Hint | undefined { + return this.hints.find(h => h.getTag() === tag); + } + + private getTargets(viewSize: Size, framePosition: Point): HTMLElement[] { + let all = window.document.querySelectorAll(TARGET_SELECTOR); + let filtered = Array.prototype.filter.call(all, (element: HTMLElement) => { + let style = window.getComputedStyle(element); + + // AREA's 'display' in Browser style is 'none' + return (element.tagName === 'AREA' || style.display !== 'none') && + style.visibility !== 'hidden' && + (element as HTMLInputElement).type !== 'hidden' && + element.offsetHeight > 0 && + !isAriaHiddenOrAriaDisabled(window, element) && + inViewport(window, element, viewSize, framePosition); + }); + return filtered; + } +} diff --git a/src/content/presenters/Hint.ts b/src/content/presenters/Hint.ts new file mode 100644 index 0000000..60c0f4c --- /dev/null +++ b/src/content/presenters/Hint.ts @@ -0,0 +1,127 @@ +import * as doms from '../../shared/utils/dom'; + +interface Point { + x: number; + y: number; +} + +const hintPosition = (element: Element): Point => { + let { left, top, right, bottom } = doms.viewportRect(element); + + if (element.tagName !== 'AREA') { + return { x: left, y: top }; + } + + return { + x: (left + right) / 2, + y: (top + bottom) / 2, + }; +}; + +export default abstract class Hint { + private hint: HTMLElement; + + private tag: string; + + constructor(target: HTMLElement, tag: string) { + this.tag = tag; + + let doc = target.ownerDocument; + if (doc === null) { + throw new TypeError('ownerDocument is null'); + } + + let { x, y } = hintPosition(target); + let { scrollX, scrollY } = window; + + let hint = doc.createElement('span'); + hint.className = 'vimvixen-hint'; + hint.textContent = tag; + hint.style.left = x + scrollX + 'px'; + hint.style.top = y + scrollY + 'px'; + + doc.body.append(hint); + + this.hint = hint; + this.show(); + } + + show(): void { + this.hint.style.display = 'inline'; + } + + hide(): void { + this.hint.style.display = 'none'; + } + + remove(): void { + this.hint.remove(); + } + + getTag(): string { + return this.tag; + } +} + +export class LinkHint extends Hint { + private target: HTMLAnchorElement | HTMLAreaElement; + + constructor(target: HTMLAnchorElement | HTMLAreaElement, tag: string) { + super(target, tag); + + this.target = target; + } + + getLink(): string { + return this.target.href; + } + + getLinkTarget(): string | null { + return this.target.getAttribute('target'); + } + + click(): void { + this.target.click(); + } +} + +export class InputHint extends Hint { + private target: HTMLElement; + + constructor(target: HTMLElement, tag: string) { + super(target, tag); + + this.target = target; + } + + activate(): void { + let target = this.target; + switch (target.tagName.toLowerCase()) { + case 'input': + switch ((target as HTMLInputElement).type) { + case 'file': + case 'checkbox': + case 'radio': + case 'submit': + case 'reset': + case 'button': + case 'image': + case 'color': + return target.click(); + default: + return target.focus(); + } + case 'textarea': + return target.focus(); + case 'button': + case 'summary': + return target.click(); + default: + if (doms.isContentEditable(target)) { + return target.focus(); + } else if (target.hasAttribute('tabindex')) { + return target.click(); + } + } + } +} diff --git a/src/content/presenters/NavigationPresenter.ts b/src/content/presenters/NavigationPresenter.ts new file mode 100644 index 0000000..66110e5 --- /dev/null +++ b/src/content/presenters/NavigationPresenter.ts @@ -0,0 +1,98 @@ +export default interface NavigationPresenter { + openHistoryPrev(): void; + + openHistoryNext(): void; + + openLinkPrev(): void; + + openLinkNext(): void; + + openParent(): void; + + openRoot(): void; + + // eslint-disable-next-line semi +} + +const REL_PATTERN: {[key: string]: RegExp} = { + prev: /^(?:prev(?:ious)?|older)\b|\u2039|\u2190|\xab|\u226a|<</i, + 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(); + } + } +} diff --git a/src/content/presenters/ScrollPresenter.ts b/src/content/presenters/ScrollPresenter.ts new file mode 100644 index 0000000..9286fb0 --- /dev/null +++ b/src/content/presenters/ScrollPresenter.ts @@ -0,0 +1,179 @@ +import * as doms from '../../shared/utils/dom'; + +const SCROLL_DELTA_X = 64; +const SCROLL_DELTA_Y = 64; + +// dirty way to store scrolling state on globally +let scrolling = false; +let lastTimeoutId: number | null = null; + +const isScrollableStyle = (element: Element): boolean => { + let { overflowX, overflowY } = window.getComputedStyle(element); + return !(overflowX !== 'scroll' && overflowX !== 'auto' && + overflowY !== 'scroll' && overflowY !== 'auto'); +}; + +const isOverflowed = (element: Element): boolean => { + return element.scrollWidth > element.clientWidth || + element.scrollHeight > element.clientHeight; +}; + +// Find a visiable and scrollable element by depth-first search. Currently +// this method is called by each scrolling, and the returned value of this +// method is not cached. That does not cause performance issue because in the +// most pages, the window is root element i,e, documentElement. +const findScrollable = (element: Element): Element | null => { + if (isScrollableStyle(element) && isOverflowed(element)) { + return element; + } + + let children = Array.from(element.children).filter(doms.isVisible); + for (let child of children) { + let scrollable = findScrollable(child); + if (scrollable) { + return scrollable; + } + } + return null; +}; + +const scrollTarget = () => { + if (isOverflowed(window.document.documentElement)) { + return window.document.documentElement; + } + if (isOverflowed(window.document.body)) { + return window.document.body; + } + let target = findScrollable(window.document.documentElement); + if (target) { + return target; + } + return window.document.documentElement; +}; + +const resetScrolling = () => { + scrolling = false; +}; + +class Scroller { + private element: Element; + + private smooth: boolean; + + constructor(element: Element, smooth: boolean) { + this.element = element; + this.smooth = smooth; + } + + scrollTo(x: number, y: number): void { + if (!this.smooth) { + this.element.scrollTo(x, y); + return; + } + this.element.scrollTo({ + left: x, + top: y, + behavior: 'smooth', + }); + this.prepareReset(); + } + + scrollBy(x: number, y: number): void { + let left = this.element.scrollLeft + x; + let top = this.element.scrollTop + y; + this.scrollTo(left, top); + } + + prepareReset(): void { + scrolling = true; + if (lastTimeoutId) { + clearTimeout(lastTimeoutId); + lastTimeoutId = null; + } + lastTimeoutId = setTimeout(resetScrolling, 100); + } +} + +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 +} + +export class ScrollPresenterImpl { + getScroll(): Point { + let target = scrollTarget(); + return { x: target.scrollLeft, y: target.scrollTop }; + } + + scrollVertically(count: number, smooth: boolean): void { + let target = scrollTarget(); + let delta = SCROLL_DELTA_Y * count; + if (scrolling) { + delta = SCROLL_DELTA_Y * count * 4; + } + new Scroller(target, smooth).scrollBy(0, delta); + } + + scrollHorizonally(count: number, smooth: boolean): void { + let target = scrollTarget(); + let delta = SCROLL_DELTA_X * count; + if (scrolling) { + delta = SCROLL_DELTA_X * count * 4; + } + new Scroller(target, smooth).scrollBy(delta, 0); + } + + scrollPages(count: number, smooth: boolean): void { + let target = scrollTarget(); + let height = target.clientHeight; + let delta = height * count; + if (scrolling) { + delta = height * count; + } + new Scroller(target, smooth).scrollBy(0, delta); + } + + scrollTo(x: number, y: number, smooth: boolean): void { + let target = scrollTarget(); + new Scroller(target, smooth).scrollTo(x, y); + } + + scrollToTop(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollLeft; + let y = 0; + new Scroller(target, smooth).scrollTo(x, y); + } + + scrollToBottom(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollLeft; + let y = target.scrollHeight; + new Scroller(target, smooth).scrollTo(x, y); + } + + scrollToHome(smooth: boolean): void { + let target = scrollTarget(); + let x = 0; + let y = target.scrollTop; + new Scroller(target, smooth).scrollTo(x, y); + } + + scrollToEnd(smooth: boolean): void { + let target = scrollTarget(); + let x = target.scrollWidth; + let y = target.scrollTop; + new Scroller(target, smooth).scrollTo(x, y); + } +} |